├── 10_Template ├── bot │ ├── tests │ │ ├── __init__.py │ │ ├── pytest.ini │ │ ├── conftest.py │ │ ├── test_start.py │ │ └── mocked_aiogram.py │ ├── handling │ │ ├── __init__.py │ │ ├── states │ │ │ ├── __init__.py │ │ │ └── watermark.py │ │ ├── dialogs │ │ │ ├── __init__.py │ │ │ └── watermark.py │ │ ├── filters │ │ │ ├── __init__.py │ │ │ └── chat_type.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── start.py │ │ │ └── get_user.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── dialog_reset.py │ │ │ ├── translator.py │ │ │ ├── database_repo.py │ │ │ └── logging.py │ │ └── schema.py │ ├── payload │ │ ├── __init__.py │ │ └── convert_task.py │ ├── __init__.py │ ├── nats_storage │ │ ├── __init__.py │ │ └── entry.py │ ├── config.py │ ├── __main__.py │ └── send_done_photos.py ├── database │ ├── __init__.py │ ├── models │ │ ├── base.py │ │ ├── __init__.py │ │ └── users.py │ ├── config │ │ ├── orm │ │ │ ├── engine.py │ │ │ ├── __init__.py │ │ │ ├── session.py │ │ │ └── mixin.py │ │ ├── __init__.py │ │ ├── base.py │ │ └── common.py │ └── migration │ │ ├── script.py.mako │ │ └── env.py ├── I18N │ ├── __init__.py │ ├── locales │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ └── txt.ftl │ │ └── ru │ │ │ └── LC_MESSAGES │ │ │ └── txt.ftl │ └── factory.py ├── img-converter │ ├── font.otf │ ├── Dockerfile │ ├── README.md │ ├── pyproject.toml │ └── app.py ├── .gitignore ├── settings.toml ├── logs │ ├── __init__.py │ ├── config.py │ └── startup.py ├── nats │ ├── nats.conf │ ├── README.md │ ├── Dockerfile │ ├── pyproject.toml │ ├── migration.py │ └── poetry.lock ├── Dockerfile ├── secrets.toml.example ├── pyproject.toml ├── config.py ├── README.md ├── LICENSE ├── app.py ├── docker-compose.yaml └── alembic.ini ├── 04_Testing ├── 03_Testing_FSM │ ├── bot │ │ ├── __init__.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── basic_commands.py │ │ │ └── ordering_food.py │ │ ├── states.py │ │ ├── __main__.py │ │ └── config_reader.py │ ├── tests │ │ ├── __init__.py │ │ ├── pytest.ini │ │ ├── conftest.py │ │ ├── test_calculator.py │ │ ├── mocked_aiogram.py │ │ └── test_ordering_food.py │ ├── settings.example.yml │ ├── README.md │ └── requirements.txt ├── 04_Testing_DB │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── migrations │ │ │ │ ├── README │ │ │ │ ├── script.py.mako │ │ │ │ ├── versions │ │ │ │ │ └── 001_initial_migration_created_tables.py │ │ │ │ └── env.py │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── models.py │ │ │ └── requests.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ └── db.py │ │ ├── states.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── basic_commands.py │ │ │ └── ordering_food.py │ │ ├── config_reader.py │ │ └── __main__.py │ ├── tests │ │ ├── __init__.py │ │ ├── pytest.ini │ │ ├── test_orders_flow_and_db.py │ │ ├── conftest.py │ │ └── mocked_aiogram.py │ ├── README.md │ ├── settings.example.yml │ ├── docker-compose.yml │ └── requirements.txt └── 02_Testing_handlers │ ├── bot │ ├── __init__.py │ ├── handlers │ │ ├── __init__.py │ │ ├── capybara_handlers.py │ │ ├── basic_commands.py │ │ ├── generate_handlers.py │ │ └── user_id_handlers.py │ ├── __main__.py │ └── config_reader.py │ ├── tests │ ├── __init__.py │ ├── pytest.ini │ ├── conftest.py │ ├── test_basic_commands.py │ ├── test_capybara_handlers.py │ ├── test_generate_handlers.py │ ├── test_dice.py │ ├── test_user_id_handlers.py │ └── mocked_aiogram.py │ ├── settings.example.yml │ ├── .vscode │ ├── settings.json │ └── launch.json │ ├── README.md │ └── requirements.txt ├── 08_Databases ├── 01-sqlalchemy-core │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ └── tables.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ └── commands.py │ │ ├── config_reader.py │ │ └── __main__.py │ ├── README.md │ ├── requirements.txt │ ├── config.example.yml │ └── docker-compose.example.yml ├── 02-sqlalchemy-orm │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── base.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── mixins.py │ │ │ │ ├── user.py │ │ │ │ └── user_games.py │ │ │ ├── __init__.py │ │ │ └── requests.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── session.py │ │ │ └── track_all_users.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── for_admin.py │ │ │ └── commands.py │ │ ├── config_reader.py │ │ └── __main__.py │ ├── README.md │ ├── config.example.yml │ ├── docker-compose.example.yml │ └── requirements.txt └── 03-alembic │ ├── before_alembic │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ │ ├── base.py │ │ │ └── requests.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ └── commands.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── session.py │ │ │ └── track_all_users.py │ │ ├── config_reader.py │ │ └── __main__.py │ └── config.example.yml │ ├── first_migration │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── migrations │ │ │ │ ├── README │ │ │ │ ├── script.py.mako │ │ │ │ ├── versions │ │ │ │ │ └── 20240814_0013_first_migration.py │ │ │ │ └── env.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ └── user.py │ │ │ ├── base.py │ │ │ └── __init__.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ └── commands.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── session.py │ │ │ └── track_all_users.py │ │ ├── config_reader.py │ │ └── __main__.py │ └── config.example.yml │ ├── migration_with_extra_steps │ ├── bot │ │ ├── __init__.py │ │ ├── db │ │ │ ├── migrations │ │ │ │ ├── README │ │ │ │ ├── script.py.mako │ │ │ │ ├── versions │ │ │ │ │ ├── 20240822_0013_first_migration.py │ │ │ │ │ ├── 20240901_0152_added_license_table.py │ │ │ │ │ └── 20240902_0221_added_expiration_date_column_to_.py │ │ │ │ └── env.py │ │ │ ├── base.py │ │ │ ├── __init__.py │ │ │ └── models │ │ │ │ ├── __init__.py │ │ │ │ ├── user.py │ │ │ │ └── licence.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ └── commands.py │ │ ├── middlewares │ │ │ ├── __init__.py │ │ │ ├── session.py │ │ │ └── track_all_users.py │ │ ├── config_reader.py │ │ └── __main__.py │ └── config.example.yml │ ├── README.md │ └── requirements.txt ├── .DS_Store ├── README.md └── .gitignore /10_Template/bot/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /10_Template/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /10_Template/bot/handling/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /10_Template/bot/payload/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasterGroosha/advanced-telegram-bots/HEAD/.DS_Store -------------------------------------------------------------------------------- /10_Template/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .__main__ import main as bot 2 | 3 | __all__ = ['bot'] 4 | -------------------------------------------------------------------------------- /10_Template/I18N/__init__.py: -------------------------------------------------------------------------------- 1 | from .factory import i18n_factory 2 | 3 | __all__ = ['i18n_factory'] 4 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /10_Template/bot/handling/states/__init__.py: -------------------------------------------------------------------------------- 1 | from .watermark import Watermark 2 | 3 | __all__ = ['Watermark'] 4 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import RegisteredUser, Order 2 | from .base import Base 3 | -------------------------------------------------------------------------------- /10_Template/bot/nats_storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .entry import NATSFSMStorage 2 | 3 | __all__ = ['NATSFSMStorage'] 4 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /10_Template/bot/handling/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | from . watermark import dialog as watermark 2 | 3 | __all__ = [watermark] 4 | -------------------------------------------------------------------------------- /10_Template/database/models/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | class Base(DeclarativeBase): 4 | pass -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /10_Template/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .users import User 3 | 4 | __all__ = ['User', 'Base'] 5 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | 3 | __all__ = [ 4 | "User", 5 | ] 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | 3 | __all__ = [ 4 | "User", 5 | ] 6 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /10_Template/img-converter/font.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MasterGroosha/advanced-telegram-bots/HEAD/10_Template/img-converter/font.otf -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /10_Template/bot/tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True 3 | asyncio_mode=auto -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import DbSessionMiddleware 2 | 3 | __all__ = [ 4 | "DbSessionMiddleware" 5 | ] 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /10_Template/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .secrets.toml 3 | venv/ 4 | .venv/ 5 | 6 | /nats/data/ 7 | .DS_Store/ 8 | __pycache__/ 9 | 10 | /nats/data/ 11 | -------------------------------------------------------------------------------- /10_Template/settings.toml: -------------------------------------------------------------------------------- 1 | [logging] 2 | level = 'info' 3 | utc = true 4 | time_format = '%Y-%m-%dT H:%M:%S' 5 | call_site = false 6 | renderer = 'text' 7 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import DeclarativeBase 2 | 3 | 4 | class Base(DeclarativeBase): 5 | pass 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .models import User 3 | 4 | __all__ = [ 5 | "Base", 6 | "User", 7 | ] 8 | -------------------------------------------------------------------------------- /10_Template/bot/handling/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat_type import ChatTypeFilter, ChatType 2 | 3 | __all__ = [ 4 | 'ChatType', 5 | 'ChatTypeFilter', 6 | ] 7 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .user_games import Game 3 | 4 | __all__ = [ 5 | "User", 6 | "Game" 7 | ] 8 | -------------------------------------------------------------------------------- /10_Template/bot/handling/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .start import start_router 2 | from .get_user import get_user_router 3 | 4 | __all__ = ['start_router', 'get_user_router'] 5 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/settings.example.yml: -------------------------------------------------------------------------------- 1 | # Токен бота. Можно взять у https://t.me/BotFather 2 | bot_token: "" # Пример: "1234567890:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRr" 3 | -------------------------------------------------------------------------------- /10_Template/I18N/locales/en/LC_MESSAGES/txt.ftl: -------------------------------------------------------------------------------- 1 | enter_image = Upload photo 2 | enter_watermark = Type watermark text 3 | in_progress = Photo in progress 4 | db_get_user = User {$user} -------------------------------------------------------------------------------- /10_Template/logs/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from .config import Config 4 | from .startup import startup 5 | 6 | __all__: List[str] = ['Config', 'startup'] 7 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/settings.example.yml: -------------------------------------------------------------------------------- 1 | # Токен бота. Можно взять у https://t.me/BotFather 2 | bot_token: "" # Пример: "1234567890:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRr" 3 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .models import User, Game 3 | 4 | __all__ = [ 5 | "Base", 6 | "User", 7 | "Game", 8 | ] 9 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .models import User 3 | 4 | __all__ = [ 5 | "Base", 6 | "User", 7 | ] 8 | -------------------------------------------------------------------------------- /10_Template/database/config/orm/engine.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Extra 2 | 3 | 4 | class EngineConfig(BaseModel): 5 | class Config: 6 | extra = Extra.allow 7 | -------------------------------------------------------------------------------- /10_Template/database/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Config as BaseDBConfig 2 | from .common import Config 3 | 4 | __all__ = [ 5 | 'BaseDBConfig', 6 | 'Config', 7 | ] 8 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .licence import License 3 | 4 | __all__ = [ 5 | "User", 6 | "License", 7 | ] 8 | -------------------------------------------------------------------------------- /10_Template/I18N/locales/ru/LC_MESSAGES/txt.ftl: -------------------------------------------------------------------------------- 1 | enter_image = Загрузите фото 2 | enter_watermark = Напишите текст вотермарки 3 | in_progress = Ща вотермарка наложится 4 | db_get_user = Юзер {$user} -------------------------------------------------------------------------------- /10_Template/nats/nats.conf: -------------------------------------------------------------------------------- 1 | server_name: "aiogram-nats" 2 | 3 | jetstream { 4 | store_dir: /data/jetstream 5 | max_mem: 1G 6 | max_file: 100G 7 | } 8 | 9 | http_port: 8222 10 | -------------------------------------------------------------------------------- /10_Template/bot/handling/states/watermark.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.state import State, StatesGroup 2 | 3 | 4 | class Watermark(StatesGroup): 5 | enter_text = State() 6 | enter_photo = State() 7 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/states.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.state import StatesGroup, State 2 | 3 | 4 | class OrderFoodStates(StatesGroup): 5 | choosing_food_name = State() 6 | choosing_food_size = State() 7 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from . import commands 3 | 4 | 5 | def get_routers() -> list[Router]: 6 | return [ 7 | commands.router 8 | ] 9 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "tests" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /10_Template/database/config/orm/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import EngineConfig 2 | from .mixin import ORMConfig 3 | from .session import SessionConfig 4 | 5 | __all__ = ['EngineConfig', 'ORMConfig', 'SessionConfig'] 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from . import commands 3 | 4 | 5 | def get_routers() -> list[Router]: 6 | return [ 7 | commands.router 8 | ] 9 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from . import commands 3 | 4 | 5 | def get_routers() -> list[Router]: 6 | return [ 7 | commands.router 8 | ] 9 | -------------------------------------------------------------------------------- /10_Template/nats/README.md: -------------------------------------------------------------------------------- 1 | This is migration util for NATS. 2 | 3 | Tool has idempotence support, safe to run multiple times for same version 4 | Set environment variable "NATS_URL" to nats address, for example "nats://nats:4222". -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from . import commands 3 | 4 | 5 | def get_routers() -> list[Router]: 6 | return [ 7 | commands.router 8 | ] 9 | -------------------------------------------------------------------------------- /10_Template/database/config/orm/session.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Extra 2 | 3 | 4 | class SessionConfig(BaseModel): 5 | expire_on_commit: bool = False 6 | 7 | class Config: 8 | extra = Extra.allow 9 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import DbSessionMiddleware 2 | from .track_all_users import TrackAllUsersMiddleware 3 | 4 | __all__ = [ 5 | "DbSessionMiddleware", 6 | "TrackAllUsersMiddleware", 7 | ] 8 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import DbSessionMiddleware 2 | from .track_all_users import TrackAllUsersMiddleware 3 | 4 | __all__ = [ 5 | "DbSessionMiddleware", 6 | "TrackAllUsersMiddleware", 7 | ] 8 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import DbSessionMiddleware 2 | from .track_all_users import TrackAllUsersMiddleware 3 | 4 | __all__ = [ 5 | "DbSessionMiddleware", 6 | "TrackAllUsersMiddleware", 7 | ] 8 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from . import commands, for_admin 3 | 4 | 5 | def get_routers() -> list[Router]: 6 | return [ 7 | for_admin.router, 8 | commands.router 9 | ] 10 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .session import DbSessionMiddleware 2 | from .track_all_users import TrackAllUsersMiddleware 3 | 4 | __all__ = [ 5 | "DbSessionMiddleware", 6 | "TrackAllUsersMiddleware", 7 | ] 8 | -------------------------------------------------------------------------------- /10_Template/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/withlogicco/poetry:1.7.1-python-3.12 2 | 3 | WORKDIR /app 4 | COPY poetry.lock pyproject.toml ./ 5 | 6 | RUN poetry install --no-interaction --no-cache --no-root 7 | 8 | COPY .. . 9 | CMD ["poetry", "run", "python", "app.py"] 10 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/README.md: -------------------------------------------------------------------------------- 1 | # Alembic 2 | 3 | В этом каталоге расположены исходники к теме "Alembic" модуля "Работа с СУБД". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=alembic). 6 | -------------------------------------------------------------------------------- /10_Template/nats/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/withlogicco/poetry:1.7.1-python-3.12 2 | 3 | WORKDIR /app 4 | COPY poetry.lock pyproject.toml ./ 5 | 6 | RUN poetry install --no-interaction --no-cache --no-root 7 | 8 | COPY .. . 9 | CMD ["poetry", "run", "python", "app.py"] 10 | -------------------------------------------------------------------------------- /10_Template/bot/payload/convert_task.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class Task(BaseModel): 7 | chat_id: int = Field() 8 | img_uuid: UUID = Field() 9 | img_format: str = Field() 10 | watermark: str = Field() 11 | -------------------------------------------------------------------------------- /10_Template/img-converter/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/withlogicco/poetry:1.7.1-python-3.12 2 | 3 | WORKDIR /app 4 | COPY poetry.lock pyproject.toml ./ 5 | 6 | RUN poetry install --no-interaction --no-cache --no-root 7 | 8 | COPY .. . 9 | CMD ["poetry", "run", "python", "app.py"] 10 | -------------------------------------------------------------------------------- /10_Template/img-converter/README.md: -------------------------------------------------------------------------------- 1 | # Font 2 | 3 | You can download any font, for example [this](https://fonts-online.ru/fonts/black-ops-one-rus-alince) 4 | Rename to `font.otf` and place close to `app.py` 5 | 6 | Also set environment variable "NATS_URL" to nats address, for example "nats://nats:4222" -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/README.md: -------------------------------------------------------------------------------- 1 | # Тестирование FSM 2 | 3 | В этом каталоге расположены исходники к теме "Тестирование FSM" модуля "Тестирование". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=testing-fsm). 6 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import basic_commands 4 | from . import ordering_food 5 | 6 | 7 | def get_routers() -> list[Router]: 8 | return [ 9 | basic_commands.router, 10 | ordering_food.router, 11 | ] 12 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy ORM 2 | 3 | В этом каталоге расположены исходники к теме "SQLAlchemy ORM" модуля "Работа с СУБД". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=sqlalchemy-orm). 6 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy Core 2 | 3 | В этом каталоге расположены исходники к теме "SQLAlchemy Core" модуля "Работа с СУБД". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=sqlalchemy-core). 6 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/README.md: -------------------------------------------------------------------------------- 1 | # Тестирование хэндлеров 2 | 3 | В этом каталоге расположены исходники к теме "Тестирование хэндлеров" модуля "Тестирование". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=testing-handlers). 6 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/README.md: -------------------------------------------------------------------------------- 1 | # Тестирование взаимодействия с СУБД 2 | 3 | В этом каталоге расположены исходники к теме "Тестирование взаимодействия с СУБД" модуля "Тестирование". 4 | 5 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=testing-db). 6 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/handlers/commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | router = Router(name="commands router") 6 | 7 | 8 | @router.message(CommandStart()) 9 | async def cmd_start(message: Message): 10 | await message.answer("Hello") 11 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/handlers/commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | router = Router(name="commands router") 6 | 7 | 8 | @router.message(CommandStart()) 9 | async def cmd_start(message: Message): 10 | await message.answer("Hello") 11 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import basic_commands 4 | from . import ordering_food 5 | from . import calculator 6 | 7 | 8 | def get_routers() -> list[Router]: 9 | return [ 10 | basic_commands.router, 11 | ordering_food.router, 12 | calculator.router 13 | ] 14 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/db/tables.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Table, MetaData, Column, BigInteger, String 2 | 3 | metadata = MetaData() 4 | 5 | 6 | users = Table( 7 | "users", 8 | metadata, 9 | Column("telegram_id", BigInteger, primary_key=True), 10 | Column("first_name", String), 11 | Column("last_name", String), 12 | ) 13 | -------------------------------------------------------------------------------- /10_Template/bot/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, SecretStr 2 | 3 | 4 | class FSM(BaseModel): 5 | data_bucket: str 6 | states_bucket: str 7 | 8 | class Config: 9 | extras = 'allow' 10 | 11 | 12 | class BotConfig(BaseModel): 13 | token: SecretStr 14 | fsm: FSM 15 | 16 | class Config: 17 | extras = 'allow' 18 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/handlers/commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | router = Router(name="commands router") 6 | 7 | 8 | @router.message(CommandStart()) 9 | async def cmd_start(message: Message): 10 | await message.answer("Hello") 11 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/settings.example.yml: -------------------------------------------------------------------------------- 1 | # Токен бота. Можно взять у https://t.me/BotFather 2 | bot_token: "" # Пример: "1234567890:AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRr" 3 | 4 | # Адрес подключения к СУБД. 5 | # Если используете docker-compose.yml из текущего каталога, то можете смело скопировать следующую строку: 6 | db_url: "postgresql+psycopg://user:password@127.0.0.1/db" 7 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/states.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.state import StatesGroup, State 2 | 3 | 4 | class OrderFoodStates(StatesGroup): 5 | choosing_food_name = State() 6 | choosing_food_size = State() 7 | 8 | 9 | class CalculatorStates(StatesGroup): 10 | choosing_first_number = State() 11 | choosing_second_number = State() 12 | choosing_operation = State() 13 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/models/mixins.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import DateTime, func 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | 7 | class TimestampMixin: 8 | created_at: Mapped[datetime] = mapped_column( 9 | DateTime(timezone=True), 10 | nullable=False, 11 | server_default=func.now(), 12 | ) -------------------------------------------------------------------------------- /10_Template/bot/handling/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .dialog_reset import DialogResetMiddleware 2 | from .logging import LoggingMiddleware 3 | from .translator import TranslatorRunnerMiddleware 4 | from .database_repo import DatabaseMiddleware 5 | 6 | __all__ = [ 7 | 'DialogResetMiddleware' 8 | 'LoggingMiddleware', 9 | 'TranslatorRunnerMiddleware', 10 | 'DatabaseMiddleware' 11 | ] 12 | -------------------------------------------------------------------------------- /10_Template/database/config/orm/mixin.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Extra 2 | 3 | from database.config.orm.engine import EngineConfig 4 | from database.config.orm.session import SessionConfig 5 | 6 | 7 | class ORMConfig(BaseModel): 8 | engine: EngineConfig = EngineConfig() 9 | session: SessionConfig = SessionConfig() 10 | 11 | class Config: 12 | extra = Extra.allow 13 | -------------------------------------------------------------------------------- /10_Template/secrets.toml.example: -------------------------------------------------------------------------------- 1 | # Docker compose ready example 2 | # rename to .secrets.toml 3 | 4 | [bot] 5 | token = "" 6 | 7 | [nats] 8 | address = "nats://nats:4222" 9 | 10 | [bot.fsm] 11 | data_bucket = 'fsm_data_aiogram' 12 | states_bucket = 'fsm_states_aiogram' 13 | 14 | [db] 15 | db_name = 'bot' 16 | adapter = 'asyncpg' 17 | username = 'username' 18 | password = 'password' 19 | host = 'postgres' 20 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/handlers/basic_commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | router = Router(name="Basic Commands Router") 6 | 7 | 8 | @router.message(CommandStart()) 9 | async def cmd_start(message: Message): 10 | await message.answer("Привет! Нажмите /food для заказа еды или /calc для перехода к калькулятору.") 11 | -------------------------------------------------------------------------------- /10_Template/img-converter/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "img-converter" 3 | version = "0.1.0" 4 | description = "" 5 | license = "MIT" 6 | authors = [""] 7 | packages = [{include = "img_converter"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | faststream = "^0.5.25" 12 | pillow = "^10.4.0" 13 | nats-py = "^2.9.0" 14 | 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import basic_commands 4 | from . import user_id_handlers 5 | from . import capybara_handlers 6 | from . import generate_handlers 7 | 8 | 9 | def get_routers() -> list[Router]: 10 | return [ 11 | basic_commands.router, 12 | user_id_handlers.router, 13 | capybara_handlers.router, 14 | generate_handlers.router 15 | ] 16 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: "advanced-tg-bots-testing-db" 2 | 3 | services: 4 | db: 5 | image: postgres:16-alpine 6 | restart: "no" 7 | environment: 8 | POSTGRES_USER: user 9 | POSTGRES_PASSWORD: password 10 | POSTGRES_DB: db 11 | volumes: 12 | - "advanced-tg-bots-testing-db-postgres:/var/lib/postgresql/data" 13 | network_mode: host 14 | 15 | volumes: 16 | advanced-tg-bots-testing-db-postgres: -------------------------------------------------------------------------------- /10_Template/nats/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nats-migration" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Your Name "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "nats_migration"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.11" 12 | nats-py = "^2.9.0" 13 | structlog = "^24.4.0" 14 | 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==23.2.1 2 | aiogram==3.3.0 3 | aiohttp==3.9.3 4 | aiosignal==1.3.1 5 | annotated-types==0.6.0 6 | attrs==23.2.0 7 | certifi==2024.2.2 8 | frozenlist==1.4.1 9 | idna==3.6 10 | iniconfig==2.0.0 11 | magic-filter==1.0.12 12 | multidict==6.0.5 13 | packaging==23.2 14 | pluggy==1.4.0 15 | pydantic==2.5.3 16 | pydantic_core==2.14.6 17 | pytest==7.4.4 18 | pytest-asyncio==0.23.4 19 | PyYAML==6.0.1 20 | typing_extensions==4.9.0 21 | yarl==1.9.4 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/db/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from bot.db.base import Base 5 | 6 | 7 | class User(Base): 8 | __tablename__ = "users" 9 | 10 | telegram_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 11 | first_name: Mapped[str] = mapped_column(String, nullable=False) 12 | last_name: Mapped[str | None] = mapped_column(String, nullable=True) 13 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==23.2.1 2 | aiogram==3.3.0 3 | aiohttp==3.9.3 4 | aiosignal==1.3.1 5 | annotated-types==0.6.0 6 | attrs==23.2.0 7 | certifi==2024.2.2 8 | frozenlist==1.4.1 9 | idna==3.6 10 | iniconfig==2.0.0 11 | magic-filter==1.0.12 12 | multidict==6.0.5 13 | packaging==23.2 14 | pluggy==1.4.0 15 | pydantic==2.5.3 16 | pydantic_core==2.14.6 17 | pytest==7.4.4 18 | pytest-asyncio==0.23.4 19 | PyYAML==6.0.1 20 | typing_extensions==4.9.0 21 | yarl==1.9.4 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from bot.db import Base 5 | 6 | 7 | class User(Base): 8 | __tablename__ = "users" 9 | 10 | telegram_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 11 | first_name: Mapped[str] = mapped_column(String, nullable=False) 12 | last_name: Mapped[str | None] = mapped_column(String, nullable=True) 13 | 14 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from bot.db import Base 5 | 6 | 7 | class User(Base): 8 | __tablename__ = "users" 9 | 10 | telegram_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 11 | first_name: Mapped[str] = mapped_column(String, nullable=False) 12 | last_name: Mapped[str | None] = mapped_column(String, nullable=True) 13 | 14 | -------------------------------------------------------------------------------- /10_Template/database/config/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from pydantic import BaseModel, Extra 4 | 5 | from database.config.orm.mixin import ORMConfig 6 | 7 | 8 | class Config(BaseModel, ABC): 9 | """Minimalistic configuration class.Expand if common configuration class can't be used.""" 10 | 11 | orm: ORMConfig = ORMConfig() 12 | 13 | @abstractmethod 14 | def uri(self) -> str: 15 | ... 16 | 17 | class Config: 18 | extras = Extra.allow 19 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==23.2.1 2 | aiogram==3.4.1 3 | aiohttp==3.9.5 4 | aiosignal==1.3.1 5 | alembic==1.13.1 6 | annotated-types==0.6.0 7 | attrs==23.2.0 8 | certifi==2024.2.2 9 | frozenlist==1.4.1 10 | greenlet==3.0.3 11 | idna==3.7 12 | magic-filter==1.0.12 13 | Mako==1.3.3 14 | MarkupSafe==2.1.5 15 | multidict==6.0.5 16 | psycopg==3.1.18 17 | psycopg-binary==3.1.18 18 | pydantic==2.5.3 19 | pydantic_core==2.14.6 20 | PyYAML==6.0.1 21 | SQLAlchemy==2.0.29 22 | typing_extensions==4.10.0 23 | yarl==1.9.4 24 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run bot", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "module": "bot", 9 | "python": "${command:python.interpreterPath}", 10 | "cwd": "${workspaceFolder}", 11 | "env": { 12 | "BOT_CONFIG_FILE": "/path/to/your/settings.yml" 13 | }, 14 | "stopOnEntry": false, 15 | "console": "integratedTerminal" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /10_Template/bot/handling/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import Command 3 | from aiogram.types import Message 4 | from aiogram_dialog import DialogManager, StartMode 5 | from fluentogram import TranslatorRunner 6 | 7 | from bot.handling.states import Watermark 8 | 9 | start_router = Router() 10 | 11 | @start_router.message(Command("start")) 12 | async def handler(msg: Message, dialog_manager: DialogManager, i18n: TranslatorRunner): 13 | await dialog_manager.start(Watermark.enter_text, mode=StartMode.RESET_STACK) 14 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | 5 | from bot.config_reader import parse_settings 6 | from bot.handlers import get_routers 7 | 8 | 9 | async def main(): 10 | settings = parse_settings() 11 | 12 | dp = Dispatcher() 13 | dp.include_routers(*get_routers()) 14 | 15 | bot = Bot(token=settings.bot_token.get_secret_value()) 16 | 17 | print("Starting polling...") 18 | await dp.start_polling(bot) 19 | 20 | 21 | if __name__ == '__main__': 22 | asyncio.run(main()) 23 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/handlers/basic_commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from bot.db.requests import ensure_user 7 | 8 | router = Router(name="Basic Commands Router") 9 | 10 | 11 | @router.message(CommandStart()) 12 | async def cmd_start(message: Message, session: AsyncSession): 13 | await ensure_user(session, message.from_user.id) 14 | await message.answer("Привет! Нажмите /food для заказа еды.") 15 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | 5 | from bot.config_reader import parse_settings 6 | from bot.handlers import get_routers 7 | 8 | 9 | async def main(): 10 | settings = parse_settings() 11 | 12 | dp = Dispatcher() 13 | dp.include_routers(*get_routers()) 14 | 15 | bot = Bot(token=settings.bot_token.get_secret_value()) 16 | 17 | print("Starting polling...") 18 | await dp.start_polling(bot) 19 | 20 | 21 | if __name__ == '__main__': 22 | asyncio.run(main()) 23 | -------------------------------------------------------------------------------- /10_Template/bot/handling/handlers/get_user.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import Command 3 | from aiogram.types import Message 4 | from fluentogram import TranslatorRunner 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from database.models import User 8 | 9 | get_user_router = Router() 10 | 11 | 12 | @get_user_router.message(Command("get_info")) 13 | async def get_user_handler(msg: Message, i18n: TranslatorRunner, db: AsyncSession): 14 | user = await db.get(User, msg.from_user.id) 15 | await msg.answer(i18n.db_get_user(user=user)) 16 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/handlers/capybara_handlers.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from aiogram import Router 4 | from aiogram.filters import Command 5 | from aiogram.types import Message, FSInputFile 6 | 7 | router = Router() 8 | 9 | 10 | def choose_random_image(): 11 | return randint(1, 10_000) 12 | 13 | 14 | @router.message(Command("capybara")) 15 | async def cmd_capybara(message: Message): 16 | await message.answer_photo( 17 | photo=FSInputFile(f"/opt/images/capybara_{choose_random_image()}.jpg"), 18 | caption="Случайная капибара" 19 | ) 20 | -------------------------------------------------------------------------------- /10_Template/database/models/users.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Text 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from .base import Base 5 | 6 | 7 | class User(Base): 8 | __tablename__ = 'users' 9 | id: Mapped[int] = mapped_column( 10 | BigInteger, 11 | primary_key=True, 12 | nullable=False, 13 | unique=True, 14 | autoincrement=True, 15 | ) 16 | telegram_id: Mapped[int] = mapped_column(BigInteger, index=True, unique=True, nullable=False) 17 | lang: Mapped[str] = mapped_column(Text, default="en", nullable=False) 18 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/config.example.yml: -------------------------------------------------------------------------------- 1 | bot: 2 | # Токен бота, можно получить у @BotFather 3 | token: "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" 4 | 5 | db: 6 | # Строка подключения к базе данных. 7 | # Ниже заполненный пример, если используете Docker Compose файл из каталога 8 | dsn: "postgresql+psycopg://superuser:superpassword@127.0.0.1/data" 9 | # Если указать "yes", то при создании движка параметр "echo" будет True, 10 | # что приведёт к выводу на экран расширенной информации о взаимодействии 11 | # SQLAlchemy и базы данных. В противном случае укажите "no" 12 | is_echo: "yes" 13 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/config.example.yml: -------------------------------------------------------------------------------- 1 | bot: 2 | # Токен бота, можно получить у @BotFather 3 | token: "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" 4 | 5 | db: 6 | # Строка подключения к базе данных. 7 | # Ниже заполненный пример, если используете Docker Compose файл из каталога 8 | dsn: "postgresql+psycopg://superuser:superpassword@127.0.0.1/data" 9 | # Если указать true, то при создании движка параметр "echo" будет True, 10 | # что приведёт к выводу на экран расширенной информации о взаимодействии 11 | # SQLAlchemy и базы данных. В противном случае укажите false 12 | is_echo: true 13 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/config.example.yml: -------------------------------------------------------------------------------- 1 | bot: 2 | # Токен бота, можно получить у @BotFather 3 | token: "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" 4 | 5 | db: 6 | # Строка подключения к базе данных. 7 | # Ниже заполненный пример, если используете Docker Compose файл из каталога 8 | dsn: "postgresql+psycopg://superuser:superpassword@127.0.0.1/data" 9 | # Если указать true, то при создании движка параметр "echo" будет True, 10 | # что приведёт к выводу на экран расширенной информации о взаимодействии 11 | # SQLAlchemy и базы данных. В противном случае укажите false 12 | is_echo: true 13 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.fsm.storage.memory import MemoryStorage 5 | 6 | from bot.handlers import get_routers 7 | from tests.mocked_aiogram import MockedBot, MockedSession 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def dp() -> Dispatcher: 12 | dispatcher = Dispatcher(storage=MemoryStorage()) 13 | dispatcher.include_routers(*get_routers()) 14 | return dispatcher 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def bot() -> MockedBot: 19 | bot = MockedBot() 20 | bot.session = MockedSession() 21 | return bot 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/config.example.yml: -------------------------------------------------------------------------------- 1 | bot: 2 | # Токен бота, можно получить у @BotFather 3 | token: "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" 4 | 5 | db: 6 | # Строка подключения к базе данных. 7 | # Ниже заполненный пример, если используете Docker Compose файл из каталога 8 | dsn: "postgresql+psycopg://superuser:superpassword@127.0.0.1/data" 9 | # Если указать true, то при создании движка параметр "echo" будет True, 10 | # что приведёт к выводу на экран расширенной информации о взаимодействии 11 | # SQLAlchemy и базы данных. В противном случае укажите false 12 | is_echo: true 13 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.fsm.storage.memory import MemoryStorage 5 | 6 | from bot.handlers import get_routers 7 | from tests.mocked_aiogram import MockedBot, MockedSession 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def dp() -> Dispatcher: 12 | dispatcher = Dispatcher(storage=MemoryStorage()) 13 | dispatcher.include_routers(*get_routers()) 14 | return dispatcher 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def bot() -> MockedBot: 19 | bot = MockedBot() 20 | bot.session = MockedSession() 21 | return bot 22 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==23.2.1 2 | aiogram==3.7.0 3 | aiohttp==3.9.3 4 | aiosignal==1.3.1 5 | alembic==1.13.1 6 | annotated-types==0.6.0 7 | attrs==23.2.0 8 | certifi==2024.2.2 9 | frozenlist==1.4.1 10 | greenlet==3.0.3 11 | idna==3.6 12 | iniconfig==2.0.0 13 | magic-filter==1.0.12 14 | Mako==1.3.2 15 | MarkupSafe==2.1.5 16 | multidict==6.0.5 17 | packaging==23.2 18 | pluggy==1.4.0 19 | psycopg==3.1.17 20 | psycopg-binary==3.1.17 21 | pydantic==2.5.3 22 | pydantic_core==2.14.6 23 | pytest==8.1.0 24 | pytest-asyncio==0.21.1 25 | PyYAML==6.0.1 26 | SQLAlchemy==2.0.25 27 | typing_extensions==4.9.0 28 | yarl==1.9.4 29 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/models/licence.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from sqlalchemy import BigInteger, String, DateTime, func 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from bot.db import Base 7 | 8 | 9 | class License(Base): 10 | __tablename__ = "licenses" 11 | 12 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 13 | email: Mapped[str] = mapped_column(String, nullable=False) 14 | key: Mapped[str] = mapped_column(String, nullable=False) 15 | expiration_date: Mapped[datetime] = mapped_column( 16 | DateTime, nullable=True 17 | ) 18 | -------------------------------------------------------------------------------- /10_Template/database/migration/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 alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, String 2 | from sqlalchemy.orm import Mapped, mapped_column, relationship 3 | 4 | from bot.db import Base 5 | from bot.db.models.mixins import TimestampMixin 6 | 7 | 8 | class User(TimestampMixin, Base): 9 | __tablename__ = "users" 10 | 11 | telegram_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 12 | first_name: Mapped[str] = mapped_column(String, nullable=False) 13 | last_name: Mapped[str | None] = mapped_column(String, nullable=True) 14 | # created_at добавляется из миксина 15 | 16 | games: Mapped[list["Game"]] = relationship(back_populates="user") 17 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/config.example.yml: -------------------------------------------------------------------------------- 1 | bot: 2 | # Токен бота, можно получить у @BotFather 3 | token: "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs" 4 | # Айди администратора бота, число 5 | admin_id: 1234567890 6 | 7 | db: 8 | # Строка подключения к базе данных. 9 | # Ниже заполненный пример, если используете Docker Compose файл из каталога 10 | dsn: "postgresql+psycopg://superuser:superpassword@127.0.0.1/data" 11 | # Если указать true, то при создании движка параметр "echo" будет True, 12 | # что приведёт к выводу на экран расширенной информации о взаимодействии 13 | # SQLAlchemy и базы данных. В противном случае укажите false 14 | is_echo: true 15 | -------------------------------------------------------------------------------- /10_Template/bot/handling/filters/chat_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from aiogram.filters import BaseFilter 4 | from aiogram.types import Chat, TelegramObject 5 | 6 | 7 | class ChatType(str, Enum): 8 | private = 'private' 9 | group = 'group' 10 | supergroup = 'supergroup' 11 | channel = 'channel' 12 | 13 | 14 | class ChatTypeFilter(BaseFilter): 15 | def __init__(self, chat_type: ChatType) -> None: 16 | super().__init__() 17 | self.chat_type = chat_type 18 | 19 | async def __call__(self, _: TelegramObject, event_chat: Chat) -> bool: 20 | if not event_chat: 21 | return False 22 | return event_chat.type == self.chat_type 23 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/handlers/basic_commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart, Command 3 | from aiogram.types import Message 4 | from aiogram.enums import DiceEmoji 5 | 6 | router = Router(name="Basic Commands Router") 7 | 8 | 9 | @router.message(CommandStart()) 10 | async def cmd_start(message: Message): 11 | await message.answer("Привет!") 12 | 13 | 14 | @router.message(Command("dice")) 15 | async def cmd_dice(message: Message): 16 | dice_msg = await message.answer_dice(emoji=DiceEmoji.DICE) 17 | if dice_msg.dice.value == 1: 18 | await message.answer("Успех!") 19 | else: 20 | await message.answer("В другой раз повезёт!") 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram-боты на Python: продвинутый уровень 2 | 3 | В этом репозитории расположены исходные тексты к различным модулям курса 4 | "[Telegram-боты на Python: продвинутый уровень](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=repo-root)". 5 | Названия каталогов соответствуют номеру модуля в оглавлении. 6 | 7 | Если вы нашли какую-либо ошибку в исходниках, пожалуйста, воспользуйтесь секцией **issues** в над списком файлов. 8 | Комментарии о текстовой (описательной) части можно оставлять прямо на Степике под каждым из шагов любого модуля. 9 | 10 | Приобрести курс можно на [платформе Stepik](https://stepik.org/a/153850?utm_source=course-github&utm_medium=readme&utm_campaign=repo-root). -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/handlers/generate_handlers.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from aiogram import Router 4 | from aiogram.exceptions import TelegramForbiddenError 5 | from aiogram.filters import Command 6 | from aiogram.types import Message 7 | 8 | router = Router() 9 | 10 | 11 | async def generate_text(): 12 | await sleep(5.0) 13 | return "<сгенерированный текст>" 14 | 15 | 16 | @router.message(Command("generate")) 17 | async def cmd_generate(message: Message): 18 | text = await generate_text() 19 | try: 20 | await message.answer(text) 21 | except TelegramForbiddenError: 22 | # Тут можно залогировать или что-то ещё 23 | print("Пользователь заблокировал бота") 24 | return 25 | -------------------------------------------------------------------------------- /10_Template/bot/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.fsm.storage.memory import MemoryStorage 5 | 6 | import logs 7 | from bot.handling.schema import assemble 8 | from bot.tests.mocked_aiogram import MockedBot, MockedSession 9 | from config import Config, parse_config 10 | 11 | 12 | async def get_dp(): 13 | return Dispatcher(storage=MemoryStorage()) 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | async def dp() -> Dispatcher: 18 | dispatcher = await assemble(dispatcher_factory=get_dp()) 19 | return dispatcher 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def bot() -> MockedBot: 24 | bot = MockedBot() 25 | bot.session = MockedSession() 26 | return bot 27 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/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 Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/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 Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/middlewares/db.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | 8 | class DbSessionMiddleware(BaseMiddleware): 9 | def __init__(self, session_pool: async_sessionmaker): 10 | super().__init__() 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | data["session"] = session 21 | return await handler(event, data) 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/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 Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/middlewares/session.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | 8 | class DbSessionMiddleware(BaseMiddleware): 9 | def __init__(self, session_pool: async_sessionmaker): 10 | super().__init__() 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | data["session"] = session 21 | return await handler(event, data) 22 | -------------------------------------------------------------------------------- /10_Template/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aiogram-gdk-course" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [""] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "aiogram_template"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | dynaconf = "^3.2.1" 13 | fluentogram = "^1.1.6" 14 | pathvalidate = "^3.1.0" 15 | greenlet = "^3.0.3" 16 | alembic = "^1.11.1" 17 | aiogram = "^3.4.1" 18 | structlog = "^24.1.0" 19 | ujson = "^5.9.0" 20 | nats-py = "^2.7.0" 21 | aiogram-dialog = "2.2.0a3" 22 | asyncpg = "^0.29.0" 23 | sqlalchemy = "^2.0.27" 24 | pyrate-limiter = "^3.2.1" 25 | pyinstrument = "^4.6.2" 26 | orjson = "^3.9.15" 27 | rich = "^13.7.0" 28 | faststream = "^0.5.28" 29 | 30 | 31 | [build-system] 32 | requires = ["poetry-core"] 33 | build-backend = "poetry.core.masonry.api" 34 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/middlewares/session.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | 8 | class DbSessionMiddleware(BaseMiddleware): 9 | def __init__(self, session_pool: async_sessionmaker): 10 | super().__init__() 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | data["session"] = session 21 | return await handler(event, data) 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/middlewares/session.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | 8 | class DbSessionMiddleware(BaseMiddleware): 9 | def __init__(self, session_pool: async_sessionmaker): 10 | super().__init__() 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | data["session"] = session 21 | return await handler(event, data) 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/middlewares/session.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | 8 | class DbSessionMiddleware(BaseMiddleware): 9 | def __init__(self, session_pool: async_sessionmaker): 10 | super().__init__() 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | data["session"] = session 21 | return await handler(event, data) 22 | -------------------------------------------------------------------------------- /10_Template/config.py: -------------------------------------------------------------------------------- 1 | from dynaconf import Dynaconf 2 | from pydantic import BaseModel 3 | 4 | from bot.config import BotConfig as TgBotConfig 5 | from database.config.common import Config as RelationalDatabaseConfig 6 | from logs.config import Config as LoggingConfig 7 | 8 | class NatsConfig(BaseModel): 9 | address: str 10 | 11 | class Config: 12 | extras = 'allow' 13 | 14 | 15 | class Config(BaseModel): 16 | bot: TgBotConfig 17 | db: RelationalDatabaseConfig 18 | logging: LoggingConfig 19 | nats: NatsConfig 20 | 21 | class Config: 22 | alias_generator = str.upper 23 | 24 | 25 | def parse_config(): 26 | settings = Dynaconf( 27 | envvar_prefix='APP_CONF', 28 | settings_files=['settings.toml', '.secrets.toml'], 29 | ) 30 | return Config.model_validate(settings.as_dict()) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store/ 2 | venv/ 3 | .venv/ 4 | /.idea 5 | /04_Testing/02_Testing_handlers/venv/ 6 | /04_Testing/02_Testing_handlers/settings.yml 7 | /04_Testing/02_Testing_handlers/.pytest_cache 8 | /04_Testing/03_Testing_FSM/venv/ 9 | /04_Testing/03_Testing_FSM/settings.yml 10 | /04_Testing/03_Testing_FSM/.pytest_cache 11 | /04_Testing/04_Testing_DB/venv/ 12 | /04_Testing/04_Testing_DB/settings.yml 13 | /04_Testing/04_Testing_DB/.pytest_cache 14 | /08_Databases/01-sqlalchemy-core/docker-compose.yml 15 | /08_Databases/01-sqlalchemy-core/config.yml 16 | /08_Databases/01-sqlalchemy-core/venv/ 17 | /08_Databases/02-sqlalchemy-orm/config.yml 18 | /08_Databases/02-sqlalchemy-orm/venv/ 19 | /08_Databases/03-alembic/before_alembic/config.yml 20 | /08_Databases/03-alembic/first_migration/config.yml 21 | /08_Databases/03-alembic/migration_with_extra_steps/config.yml 22 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/db/requests.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects.postgresql import insert as upsert 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | 4 | from bot.db.models import User 5 | 6 | 7 | async def upsert_user( 8 | session: AsyncSession, 9 | telegram_id: int, 10 | first_name: str, 11 | last_name: str | None = None, 12 | ): 13 | stmt = upsert(User).values( 14 | { 15 | "telegram_id": telegram_id, 16 | "first_name": first_name, 17 | "last_name": last_name, 18 | } 19 | ) 20 | stmt = stmt.on_conflict_do_update( 21 | index_elements=['telegram_id'], 22 | set_=dict( 23 | first_name=first_name, 24 | last_name=last_name, 25 | ), 26 | ) 27 | await session.execute(stmt) 28 | await session.commit() 29 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/models/user_games.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from sqlalchemy import BigInteger, ForeignKey, Integer, Uuid, text 4 | from sqlalchemy.orm import Mapped, mapped_column, relationship 5 | 6 | from bot.db import Base 7 | from bot.db.models.mixins import TimestampMixin 8 | 9 | 10 | class Game(TimestampMixin, Base): 11 | __tablename__ = "games" 12 | 13 | id: Mapped[UUID] = mapped_column( 14 | Uuid, 15 | primary_key=True, 16 | server_default=text("gen_random_uuid()") 17 | ) 18 | user_id: Mapped[int] = mapped_column( 19 | BigInteger, 20 | ForeignKey("users.telegram_id", ondelete="CASCADE"), 21 | ) 22 | score: Mapped[int] = mapped_column(Integer, nullable=False) 23 | # created_at добавляется из миксина 24 | 25 | user: Mapped["User"] = relationship(back_populates="games") -------------------------------------------------------------------------------- /10_Template/I18N/factory.py: -------------------------------------------------------------------------------- 1 | from fluent_compiler.bundle import FluentBundle 2 | from fluentogram import FluentTranslator, TranslatorHub 3 | 4 | DIR_PATH = 'I18N/locales' 5 | 6 | 7 | def i18n_factory() -> TranslatorHub: 8 | return TranslatorHub( 9 | {'ru': ('ru', 'en'), 'en': 'en'}, 10 | [ 11 | FluentTranslator( 12 | locale='ru', 13 | translator=FluentBundle.from_files( 14 | locale='ru', 15 | filenames=[f'{DIR_PATH}/ru/LC_MESSAGES/txt.ftl']), 16 | ), 17 | FluentTranslator( 18 | locale='en', 19 | translator=FluentBundle.from_files( 20 | locale='en', 21 | filenames=[f'{DIR_PATH}/en/LC_MESSAGES/txt.ftl']), 22 | ), 23 | ], 24 | root_locale='en', 25 | ) 26 | -------------------------------------------------------------------------------- /10_Template/nats/migration.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import os 4 | import nats 5 | import structlog 6 | from nats.js.api import KeyValueConfig, ObjectStoreConfig 7 | 8 | 9 | async def main(): 10 | logger = structlog.get_logger(__name__) 11 | nc = await nats.connect(os.getenv("NATS_URL")) 12 | js = nc.jetstream() 13 | logger.debug('NATS connection established') 14 | # FSM buckets 15 | await js.create_key_value(KeyValueConfig("fsm_data_aiogram")) 16 | await js.create_key_value(KeyValueConfig("fsm_states_aiogram")) 17 | # Watermarker 18 | await js.create_key_value(KeyValueConfig("watermarker-tasks")) 19 | await js.create_key_value(KeyValueConfig("watermarker-done-tasks")) 20 | await js.create_object_store("watermarker-images", ObjectStoreConfig()) 21 | logger.debug('NATS Buckets created') 22 | 23 | 24 | if __name__ == '__main__': 25 | asyncio.run(main()) 26 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/handlers/user_id_handlers.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.types import Message, CallbackQuery, InlineKeyboardButton 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | from aiogram.filters import Command 5 | 6 | 7 | router = Router() 8 | 9 | 10 | @router.message(Command("id")) 11 | async def cmd_id(message: Message): 12 | builder = InlineKeyboardBuilder() 13 | builder.add( 14 | InlineKeyboardButton( 15 | text="Узнать свой ID", 16 | callback_data="myid" 17 | ) 18 | ) 19 | await message.answer( 20 | "Нажмите на кнопку ниже:", 21 | reply_markup=builder.as_markup() 22 | ) 23 | 24 | 25 | @router.callback_query(F.data == "myid") 26 | async def get_my_id(callback: CallbackQuery): 27 | await callback.answer( 28 | text=f"Ваш айди: {callback.from_user.id}" 29 | ) 30 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from pathlib import Path 3 | 4 | from pydantic import BaseModel, SecretStr 5 | from yaml import load 6 | 7 | try: 8 | from yaml import CSafeLoader as SafeLoader 9 | except ImportError: 10 | from yaml import SafeLoader 11 | 12 | 13 | class Settings(BaseModel): 14 | bot_token: SecretStr 15 | 16 | 17 | def parse_settings() -> Settings: 18 | env_var = "BOT_CONFIG_FILE" 19 | 20 | file_path = getenv(env_var) 21 | if file_path is None: 22 | error = f"Environment variable {env_var} is missing or empty" 23 | raise ValueError(error) 24 | 25 | if not Path(file_path).is_file(): 26 | error = f"Path {file_path} is not a file or doesn't exist" 27 | raise ValueError(error) 28 | 29 | with open(file_path, "rt") as file: 30 | config_data = load(file, SafeLoader) 31 | 32 | return Settings.model_validate(config_data) 33 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15-alpine 6 | restart: "no" # избавляемся от автоматической перезагрузки 7 | ports: 8 | - "127.0.0.1:5432:5432" 9 | environment: 10 | # Superuser username/password 11 | POSTGRES_USER: superuser 12 | POSTGRES_PASSWORD: superpassword 13 | POSTGRES_DB: data 14 | volumes: 15 | - "01-sqlalchemy-core-postgres:/var/lib/postgresql/data" 16 | 17 | pgadmin: 18 | image: dpage/pgadmin4:latest 19 | restart: "no" 20 | ports: 21 | - "127.0.0.1:8080:80" 22 | environment: 23 | PGADMIN_DEFAULT_EMAIL: a@a.com 24 | PGADMIN_DEFAULT_PASSWORD: pgadmin 25 | volumes: 26 | - "01-sqlalchemy-core-pgadmin:/var/lib/pgadmin" 27 | depends_on: 28 | - postgres 29 | 30 | 31 | volumes: 32 | 01-sqlalchemy-core-pgadmin: 33 | 01-sqlalchemy-core-postgres: -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from pathlib import Path 3 | 4 | from pydantic import BaseModel, SecretStr 5 | from yaml import load 6 | 7 | try: 8 | from yaml import CSafeLoader as SafeLoader 9 | except ImportError: 10 | from yaml import SafeLoader 11 | 12 | 13 | class Settings(BaseModel): 14 | bot_token: SecretStr 15 | 16 | 17 | def parse_settings() -> Settings: 18 | env_var = "BOT_CONFIG_FILE" 19 | 20 | file_path = getenv(env_var) 21 | if file_path is None: 22 | error = f"Environment variable {env_var} is missing or empty" 23 | raise ValueError(error) 24 | 25 | if not Path(file_path).is_file(): 26 | error = f"Path {file_path} is not a file or doesn't exist" 27 | raise ValueError(error) 28 | 29 | with open(file_path, "rt") as file: 30 | config_data = load(file, SafeLoader) 31 | 32 | return Settings.model_validate(config_data) 33 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/handlers/for_admin.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router 2 | from aiogram.filters import Command, MagicData 3 | from aiogram.types import Message 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from bot.db.requests import get_last_games 7 | 8 | router = Router(name="admin commands router") 9 | # Фильтр: роутер доступен только chat id, равному admin_id, 10 | # который передан в диспетчер 11 | router.message.filter(MagicData(F.event.chat.id == F.admin_id)) # noqa 12 | 13 | 14 | @router.message(Command("last3")) 15 | async def cmd_last3( 16 | message: Message, 17 | session: AsyncSession, 18 | ): 19 | games = await get_last_games( 20 | session=session, 21 | number_of_games=3 22 | ) 23 | result = [ 24 | "Последние 3 игры:\n" 25 | ] 26 | for game in games: 27 | result.append( 28 | f"{game.user.first_name} набрал(а) {game.score} очк." 29 | ) 30 | await message.answer("\n".join(result)) 31 | -------------------------------------------------------------------------------- /10_Template/README.md: -------------------------------------------------------------------------------- 1 | # Aiogram3 bot template project 2 | 3 | Simple bot that draws watermarks on user images. WIP. 4 | All services in single git repository, monorepository. That's for compact view. We assume that in real job you have git repo per service. 5 | 6 | # Features: 7 | - Aiogram3 8 | - Aiogram-Dialog 9 | - PostgreSQL 10 | - I18n based on Fluent (Fluentogram) 11 | - Bot and worker separated using NATS 12 | - Configs through Dynaconf and Pydantic 13 | - Logging with structlog 14 | 15 | # Usage 16 | 17 | ## Run locally 18 | - git clone https://github.com/Arustinal/aiogram-gdk-course-example 19 | - `cd aiogram-gdk-course-example` 20 | - fill `secrets.toml.example` and rename to `.secrets.toml`, for Linux `mv secrets.toml.example .secrets.toml` 21 | - also set environment variables as described in readme files of services 22 | - run `docker-compose --profile=infrastructure up`, profiles in `docker-compose.yaml` file 23 | 24 | ## Bot 25 | - Works in private chats 26 | - `/start` 27 | - `/get_user` 28 | 29 | # In case of problems 30 | - go to `Issues` and describe the problem 31 | - go to telegram chat [link](https://t.me/aiogram_stepik_course) 32 | -------------------------------------------------------------------------------- /10_Template/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Arustinal 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 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | name: "advanced-course-sqlalchemy-orm" 2 | 3 | services: 4 | 5 | postgres: 6 | image: postgres:16-alpine 7 | # поскольку это тестовый стенд, то не перезагружаем автоматически 8 | restart: "no" 9 | ports: 10 | - "127.0.0.1:5432:5432" 11 | environment: 12 | # логин и пароль первого (супер)пользователя, а также название БД 13 | POSTGRES_USER: superuser 14 | POSTGRES_PASSWORD: superpassword 15 | POSTGRES_DB: data 16 | volumes: 17 | - "advanced-course-sqlalchemy-orm-postgres:/var/lib/postgresql/data" 18 | 19 | pgadmin: 20 | image: dpage/pgadmin4:8.9 21 | # поскольку это тестовый стенд, то не перезагружаем автоматически 22 | restart: "no" 23 | ports: 24 | - "127.0.0.1:8080:80" 25 | environment: 26 | PGADMIN_DEFAULT_EMAIL: a@a.com 27 | PGADMIN_DEFAULT_PASSWORD: pgadmin 28 | volumes: 29 | - "advanced-course-sqlalchemy-orm-pgadmin:/var/lib/pgadmin" 30 | depends_on: 31 | - postgres 32 | 33 | 34 | volumes: 35 | advanced-course-sqlalchemy-orm-postgres: 36 | advanced-course-sqlalchemy-orm-pgadmin: -------------------------------------------------------------------------------- /10_Template/bot/tests/test_start.py: -------------------------------------------------------------------------------- 1 | # Sample test 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | from aiogram.dispatcher.event.bases import UNHANDLED 7 | from aiogram.enums import ChatType 8 | from aiogram.methods import SendMessage 9 | from aiogram.methods.base import TelegramType 10 | from aiogram.types import Update, Chat, User, Message 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_cmd_start(dp, bot): 15 | bot.add_result_for( 16 | method=SendMessage, 17 | ok=True, 18 | ) 19 | chat = Chat(id=1234567, type=ChatType.PRIVATE) 20 | user = User(id=1234567, is_bot=False, first_name="User") 21 | message = Message( 22 | message_id=1, 23 | chat=chat, 24 | from_user=user, 25 | text="/start", 26 | date=datetime.now() 27 | ) 28 | result = await dp.feed_update( 29 | bot, 30 | Update(message=message, update_id=1) 31 | ) 32 | assert result is not UNHANDLED 33 | outgoing_message: TelegramType = bot.get_request() 34 | assert isinstance(outgoing_message, SendMessage) 35 | assert outgoing_message.text == "Привет!" -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/migrations/versions/20240814_0013_first_migration.py: -------------------------------------------------------------------------------- 1 | """First migration 2 | 3 | Revision ID: 4ceaba15382c 4 | Revises: 5 | Create Date: 2024-08-22 00:13:02.053760 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '4ceaba15382c' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('users', 24 | sa.Column('telegram_id', sa.BigInteger(), nullable=False), 25 | sa.Column('first_name', sa.String(), nullable=False), 26 | sa.Column('last_name', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('telegram_id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('users') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/test_basic_commands.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from aiogram.dispatcher.event.bases import UNHANDLED 5 | from aiogram.enums import ChatType 6 | from aiogram.methods import SendMessage 7 | from aiogram.methods.base import TelegramType 8 | from aiogram.types import Update, Chat, User, Message 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_cmd_start(dp, bot): 13 | bot.add_result_for( 14 | method=SendMessage, 15 | ok=True, 16 | # result сейчас можно пропустить 17 | ) 18 | chat = Chat(id=1234567, type=ChatType.PRIVATE) 19 | user = User(id=1234567, is_bot=False, first_name="User") 20 | message = Message( 21 | message_id=1, 22 | chat=chat, 23 | from_user=user, 24 | text="/start", 25 | date=datetime.now() 26 | ) 27 | result = await dp.feed_update( 28 | bot, 29 | Update(message=message, update_id=1) 30 | ) 31 | assert result is not UNHANDLED 32 | outgoing_message: TelegramType = bot.get_request() 33 | assert isinstance(outgoing_message, SendMessage) 34 | assert outgoing_message.text == "Привет!" 35 | -------------------------------------------------------------------------------- /10_Template/database/config/common.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Extra, SecretStr 4 | 5 | from database.config import BaseDBConfig 6 | from database.config.orm.mixin import ORMConfig 7 | 8 | 9 | class Config(BaseDBConfig): 10 | """Configuration for a common databases like PostgreSQL""" 11 | 12 | db_type: SecretStr = SecretStr('postgresql') 13 | db_name: SecretStr 14 | adapter: Optional[str] 15 | username: SecretStr 16 | password: SecretStr 17 | host: SecretStr # port included! example: 'localhost:5432' 18 | orm: ORMConfig = ORMConfig() 19 | timeout: int = 60 20 | 21 | @property 22 | def uri(self) -> str: # noqa: WPS210 type: ignore 23 | db_type = self.db_type.get_secret_value() 24 | db_name = self.db_name.get_secret_value() 25 | username = self.username.get_secret_value() 26 | password = self.password.get_secret_value() 27 | host = self.host.get_secret_value() 28 | adapter = f'+{self.adapter}' if self.adapter else '' 29 | return f'{db_type}{adapter}://{username}:{password}@{host}/{db_name}' # noqa: WPS221 30 | 31 | class Config: 32 | extra = Extra.allow 33 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/migrations/versions/20240822_0013_first_migration.py: -------------------------------------------------------------------------------- 1 | """First migration 2 | 3 | Revision ID: 4ceaba15382c 4 | Revises: 5 | Create Date: 2024-08-22 00:13:02.053760 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '4ceaba15382c' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('users', 24 | sa.Column('telegram_id', sa.BigInteger(), nullable=False), 25 | sa.Column('first_name', sa.String(), nullable=False), 26 | sa.Column('last_name', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('telegram_id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | # Не удаляем таблицы сразу 35 | # op.drop_table('users') 36 | pass 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/migrations/versions/20240901_0152_added_license_table.py: -------------------------------------------------------------------------------- 1 | """Added license table 2 | 3 | Revision ID: ab57f3c60220 4 | Revises: 4ceaba15382c 5 | Create Date: 2024-09-01 01:52:16.262613 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'ab57f3c60220' 16 | down_revision: Union[str, None] = '4ceaba15382c' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('licenses', 24 | sa.Column('id', sa.BigInteger(), nullable=False), 25 | sa.Column('email', sa.String(), nullable=False), 26 | sa.Column('key', sa.String(), nullable=False), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | # Не удаляем таблицы сразу 35 | # op.drop_table('licenses') 36 | pass 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from pathlib import Path 3 | 4 | from pydantic import BaseModel, SecretStr, PostgresDsn 5 | from yaml import load 6 | 7 | try: 8 | from yaml import CSafeLoader as SafeLoader 9 | except ImportError: 10 | from yaml import SafeLoader 11 | 12 | 13 | class Settings(BaseModel): 14 | bot_token: SecretStr 15 | db_url: PostgresDsn 16 | 17 | 18 | def parse_settings() -> Settings: 19 | # Название переменной окружения, 20 | # значение которой есть путь к файлу конфигурации для процесса 21 | env_var = "BOT_CONFIG_FILE" 22 | 23 | file_path = getenv(env_var) 24 | # Если переменная окружения не задана, ошибка. 25 | if file_path is None: 26 | error = f"Environment variable {env_var} is missing or empty" 27 | raise ValueError(error) 28 | 29 | # Если переменная окружения задана, но такого файла нет, ошибка. 30 | if not Path(file_path).is_file(): 31 | error = f"Path {file_path} is not a file or doesn't exist" 32 | raise ValueError(error) 33 | 34 | # Чтение файла и его парсинг библиотекой pyyaml 35 | with open(file_path, "rt") as file: 36 | config_data = load(file, SafeLoader) 37 | 38 | # Возвращается объект класса Settings 39 | return Settings.model_validate(config_data) 40 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.in -o requirements.txt 3 | aiofiles==23.2.1 4 | # via aiogram 5 | aiogram==3.8.0 6 | # via -r requirements.in 7 | aiohttp==3.9.5 8 | # via aiogram 9 | aiosignal==1.3.1 10 | # via aiohttp 11 | annotated-types==0.7.0 12 | # via pydantic 13 | attrs==23.2.0 14 | # via aiohttp 15 | cachetools==5.3.3 16 | # via -r requirements.in 17 | certifi==2024.6.2 18 | # via aiogram 19 | frozenlist==1.4.1 20 | # via 21 | # aiohttp 22 | # aiosignal 23 | greenlet==3.0.3 24 | # via sqlalchemy 25 | idna==3.7 26 | # via yarl 27 | magic-filter==1.0.12 28 | # via aiogram 29 | multidict==6.0.5 30 | # via 31 | # aiohttp 32 | # yarl 33 | psycopg==3.2.1 34 | # via -r requirements.in 35 | psycopg-binary==3.2.1 36 | # via -r requirements.in 37 | pydantic==2.7.4 38 | # via aiogram 39 | pydantic-core==2.18.4 40 | # via pydantic 41 | pyyaml==6.0.1 42 | # via -r requirements.in 43 | sqlalchemy==2.0.31 44 | # via -r requirements.in 45 | typing-extensions==4.12.2 46 | # via 47 | # aiogram 48 | # psycopg 49 | # pydantic 50 | # pydantic-core 51 | # sqlalchemy 52 | yarl==1.9.4 53 | # via aiohttp 54 | -------------------------------------------------------------------------------- /10_Template/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import nats 5 | import structlog 6 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker 7 | 8 | import logs 9 | from bot import bot 10 | from config import Config, parse_config 11 | 12 | async def main() -> None: # noqa: WPS217 13 | config: Config = parse_config() 14 | logs.startup(config.logging) 15 | logger = structlog.get_logger(__name__) 16 | await logger.info('App is starting, configs parsed successfully') 17 | 18 | engine = create_async_engine(config.db.uri, **config.db.orm.engine.dict()) 19 | session_maker = async_sessionmaker( 20 | engine, **config.db.orm.session.dict(), class_=AsyncSession 21 | ) 22 | 23 | try: 24 | await asyncio.gather( 25 | bot( 26 | config.bot, 27 | nats_address=config.nats.address, 28 | session_maker=session_maker, 29 | ), 30 | ) 31 | except SystemExit: 32 | await logger.info('System shutdown') 33 | except KeyboardInterrupt: 34 | await logger.info('Shutdown by external call ( KeyboardInterrupt )') 35 | except Exception as e: 36 | await logger.exception('Abnormal shutdown detected, critical error happened', e) 37 | 38 | if __name__ == '__main__': 39 | asyncio.run(main()) 40 | -------------------------------------------------------------------------------- /10_Template/bot/handling/middlewares/dialog_reset.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.fsm.state import State 5 | from aiogram.types import TelegramObject, Update 6 | from aiogram_dialog import StartMode 7 | from aiogram_dialog.api.exceptions import UnknownIntent 8 | from structlog import get_logger 9 | 10 | 11 | class DialogResetMiddleware(BaseMiddleware): 12 | def __init__(self, init_state: State, mode: StartMode) -> None: 13 | self.init_state = init_state 14 | self.mode = mode 15 | self.logger = get_logger(self.__class__.__name__) 16 | 17 | async def __call__( 18 | self, 19 | event_handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 20 | event: Update, 21 | ctx_data: Dict[str, Any], 22 | ) -> None: 23 | await self.logger.debug('DialogResetMiddleware begun') 24 | try: 25 | await event_handler(event, ctx_data) 26 | except UnknownIntent: 27 | await self.logger.info(f'Unknown intent {type(ctx_data)}') 28 | manager = ctx_data.get('dialog_manager') 29 | if manager: 30 | await manager.start(self.init_state, mode=self.mode) 31 | await event.callback_query.answer() 32 | await self.logger.debug('DialogResetMiddleware end') 33 | -------------------------------------------------------------------------------- /10_Template/bot/handling/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable 2 | 3 | import structlog 4 | from aiogram import Dispatcher 5 | from aiogram_dialog import setup_dialogs, StartMode 6 | 7 | from bot.handling import dialogs 8 | from bot.handling.filters import ChatType, ChatTypeFilter 9 | from bot.handling.handlers import start_router 10 | from bot.handling.handlers import get_user_router 11 | 12 | from bot.handling.middlewares import ( 13 | DialogResetMiddleware, 14 | TranslatorRunnerMiddleware, 15 | DatabaseMiddleware, 16 | ) 17 | from bot.handling.middlewares.logging import LoggingMiddleware 18 | from bot.handling.states import Watermark 19 | 20 | logger = structlog.getLogger('schema') 21 | 22 | 23 | async def assemble( 24 | dispatcher_factory: Awaitable[Dispatcher] 25 | ) -> Dispatcher: 26 | dp = await dispatcher_factory 27 | setup_dialogs(dp) 28 | dp.update.middleware(LoggingMiddleware()) 29 | t = TranslatorRunnerMiddleware() 30 | dp.message.middleware(t) 31 | dp.callback_query.middleware(t) 32 | db = DatabaseMiddleware('_db_session_maker') 33 | dp.message.middleware(db) 34 | dp.update.middleware(DialogResetMiddleware(init_state=Watermark.enter_text, mode=StartMode.RESET_STACK)) 35 | dp.update.filter(ChatTypeFilter(ChatType.private)) 36 | dp.include_routers(dialogs.watermark, start_router, get_user_router) 37 | return dp 38 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | from typing import TypeVar, Type 4 | 5 | from pydantic import BaseModel, SecretStr, PostgresDsn 6 | from yaml import load 7 | 8 | try: 9 | from yaml import CSafeLoader as SafeLoader 10 | except ImportError: 11 | from yaml import SafeLoader 12 | 13 | ConfigType = TypeVar("ConfigType", bound=BaseModel) 14 | 15 | 16 | class BotConfig(BaseModel): 17 | token: SecretStr 18 | 19 | 20 | class DbConfig(BaseModel): 21 | dsn: PostgresDsn 22 | is_echo: bool 23 | 24 | 25 | @lru_cache(maxsize=1) 26 | def parse_config_file() -> dict: 27 | # Проверка наличия переменной окружения, которая переопределяет путь к конфигу 28 | file_path = getenv("BOT_CONFIG") 29 | if file_path is None: 30 | error = "Could not find settings file" 31 | raise ValueError(error) 32 | # Чтение файла, попытка распарсить его как YAML 33 | with open(file_path, "rb") as file: 34 | config_data = load(file, Loader=SafeLoader) 35 | return config_data 36 | 37 | 38 | @lru_cache 39 | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: 40 | config_dict = parse_config_file() 41 | if root_key not in config_dict: 42 | error = f"Key {root_key} not found" 43 | raise ValueError(error) 44 | return model.model_validate(config_dict[root_key]) 45 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | from typing import TypeVar, Type 4 | 5 | from pydantic import BaseModel, SecretStr, PostgresDsn 6 | from yaml import load 7 | 8 | try: 9 | from yaml import CSafeLoader as SafeLoader 10 | except ImportError: 11 | from yaml import SafeLoader 12 | 13 | ConfigType = TypeVar("ConfigType", bound=BaseModel) 14 | 15 | 16 | class BotConfig(BaseModel): 17 | token: SecretStr 18 | 19 | 20 | class DbConfig(BaseModel): 21 | dsn: PostgresDsn 22 | is_echo: bool 23 | 24 | 25 | @lru_cache(maxsize=1) 26 | def parse_config_file() -> dict: 27 | # Проверка наличия переменной окружения, которая переопределяет путь к конфигу 28 | file_path = getenv("BOT_CONFIG") 29 | if file_path is None: 30 | error = "Could not find settings file" 31 | raise ValueError(error) 32 | # Чтение файла, попытка распарсить его как YAML 33 | with open(file_path, "rb") as file: 34 | config_data = load(file, Loader=SafeLoader) 35 | return config_data 36 | 37 | 38 | @lru_cache 39 | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: 40 | config_dict = parse_config_file() 41 | if root_key not in config_dict: 42 | error = f"Key {root_key} not found" 43 | raise ValueError(error) 44 | return model.model_validate(config_dict[root_key]) 45 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | from typing import TypeVar, Type 4 | 5 | from pydantic import BaseModel, SecretStr, PostgresDsn 6 | from yaml import load 7 | 8 | try: 9 | from yaml import CSafeLoader as SafeLoader 10 | except ImportError: 11 | from yaml import SafeLoader 12 | 13 | ConfigType = TypeVar("ConfigType", bound=BaseModel) 14 | 15 | 16 | class BotConfig(BaseModel): 17 | token: SecretStr 18 | 19 | 20 | class DbConfig(BaseModel): 21 | dsn: PostgresDsn 22 | is_echo: bool 23 | 24 | 25 | @lru_cache(maxsize=1) 26 | def parse_config_file() -> dict: 27 | # Проверка наличия переменной окружения, которая переопределяет путь к конфигу 28 | file_path = getenv("BOT_CONFIG") 29 | if file_path is None: 30 | error = "Could not find settings file" 31 | raise ValueError(error) 32 | # Чтение файла, попытка распарсить его как YAML 33 | with open(file_path, "rb") as file: 34 | config_data = load(file, Loader=SafeLoader) 35 | return config_data 36 | 37 | 38 | @lru_cache 39 | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: 40 | config_dict = parse_config_file() 41 | if root_key not in config_dict: 42 | error = f"Key {root_key} not found" 43 | raise ValueError(error) 44 | return model.model_validate(config_dict[root_key]) 45 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | from typing import TypeVar, Type 4 | 5 | from pydantic import BaseModel, SecretStr, PostgresDsn 6 | from yaml import load 7 | 8 | try: 9 | from yaml import CSafeLoader as SafeLoader 10 | except ImportError: 11 | from yaml import SafeLoader 12 | 13 | ConfigType = TypeVar("ConfigType", bound=BaseModel) 14 | 15 | 16 | class BotConfig(BaseModel): 17 | token: SecretStr 18 | 19 | 20 | class DbConfig(BaseModel): 21 | dsn: PostgresDsn 22 | is_echo: bool 23 | 24 | 25 | @lru_cache(maxsize=1) 26 | def parse_config_file() -> dict: 27 | # Проверка наличия переменной окружения, которая переопределяет путь к конфигу 28 | file_path = getenv("BOT_CONFIG") 29 | if file_path is None: 30 | error = "Could not find settings file" 31 | raise ValueError(error) 32 | # Чтение файла, попытка распарсить его как YAML 33 | with open(file_path, "rb") as file: 34 | config_data = load(file, Loader=SafeLoader) 35 | return config_data 36 | 37 | 38 | @lru_cache 39 | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: 40 | config_dict = parse_config_file() 41 | if root_key not in config_dict: 42 | error = f"Key {root_key} not found" 43 | raise ValueError(error) 44 | return model.model_validate(config_dict[root_key]) 45 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/config_reader.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os import getenv 3 | from typing import TypeVar, Type 4 | 5 | from pydantic import BaseModel, SecretStr, PostgresDsn 6 | from yaml import load 7 | 8 | try: 9 | from yaml import CSafeLoader as SafeLoader 10 | except ImportError: 11 | from yaml import SafeLoader 12 | 13 | ConfigType = TypeVar("ConfigType", bound=BaseModel) 14 | 15 | 16 | class BotConfig(BaseModel): 17 | token: SecretStr 18 | admin_id: int 19 | 20 | 21 | class DbConfig(BaseModel): 22 | dsn: PostgresDsn 23 | is_echo: bool 24 | 25 | 26 | @lru_cache(maxsize=1) 27 | def parse_config_file() -> dict: 28 | # Проверка наличия переменной окружения, которая переопределяет путь к конфигу 29 | file_path = getenv("BOT_CONFIG") 30 | if file_path is None: 31 | error = "Could not find settings file" 32 | raise ValueError(error) 33 | # Чтение файла, попытка распарсить его как YAML 34 | with open(file_path, "rb") as file: 35 | config_data = load(file, Loader=SafeLoader) 36 | return config_data 37 | 38 | 39 | @lru_cache 40 | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType: 41 | config_dict = parse_config_file() 42 | if root_key not in config_dict: 43 | error = f"Key {root_key} not found" 44 | raise ValueError(error) 45 | return model.model_validate(config_dict[root_key]) 46 | -------------------------------------------------------------------------------- /10_Template/logs/config.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from pydantic import BaseModel, field_validator 4 | 5 | 6 | class Levels(int, enum.Enum): # noqa: WPS600 7 | critical = 50 8 | fatal = 50 9 | error = 40 10 | warning = 30 11 | info = 20 12 | debug = 10 13 | 14 | 15 | class StringLevels(str, enum.Enum): # noqa: WPS600 16 | critical = 'CRITICAL' 17 | fatal = 'FATAL' 18 | error = 'ERROR' 19 | warning = 'WARNING' 20 | info = 'INFO' 21 | debug = 'DEBUG' 22 | 23 | 24 | class LogsRenderer(str, enum.Enum): 25 | text = 'TEXT' 26 | json = 'JSON' 27 | 28 | 29 | class Config(BaseModel): 30 | level: Levels | StringLevels 31 | time_format: str = 'utc' 32 | utc: bool = True 33 | record_format: str = '' 34 | call_site: bool = True 35 | renderer: LogsRenderer = LogsRenderer.text 36 | 37 | @field_validator('level', mode='before') 38 | def string_level_upper(cls, level: Levels | StringLevels) -> str | int: # noqa: N805 39 | if isinstance(level, str): 40 | return level.upper() 41 | return level 42 | 43 | @field_validator('renderer', mode='before') 44 | def string_renderer_upper(cls, renderer: LogsRenderer) -> str | int: # noqa: N805 45 | if isinstance(renderer, str): 46 | return renderer.upper() 47 | return renderer 48 | 49 | class Config: 50 | extras = 'allow' 51 | use_enum_values = True 52 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/handlers/commands.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from aiogram import Router 4 | from aiogram.enums.dice_emoji import DiceEmoji 5 | from aiogram.filters import CommandStart, Command 6 | from aiogram.types import Message 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | 9 | from bot.db.requests import ( 10 | add_score, get_total_score_for_user 11 | ) 12 | 13 | router = Router(name="commands router") 14 | 15 | 16 | @router.message(CommandStart()) 17 | async def cmd_start(message: Message): 18 | await message.answer("Привет! Нажми /play и играй!") 19 | 20 | 21 | @router.message(Command("play")) 22 | async def cmd_warn( 23 | message: Message, 24 | session: AsyncSession, 25 | ): 26 | dice_msg = await message.answer_dice( 27 | emoji=DiceEmoji.DICE 28 | ) 29 | score = dice_msg.dice.value 30 | await add_score(session, message.from_user.id, score) 31 | await sleep(2.0) # примерное время анимации кубика на клиенте 32 | await message.answer(f"Выпало число {score}") 33 | 34 | 35 | @router.message(Command("stats")) 36 | async def cmd_stats( 37 | message: Message, 38 | session: AsyncSession, 39 | ): 40 | total_score: int = await get_total_score_for_user( 41 | session, message.from_user.id 42 | ) 43 | await message.answer( 44 | f"Привет, {message.from_user.first_name}! " 45 | f"Твой суммарный счёт: {total_score}" 46 | ) 47 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/middlewares/track_all_users.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any, cast 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject, Message 5 | from cachetools import TTLCache 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from bot.db.requests import upsert_user 9 | 10 | 11 | class TrackAllUsersMiddleware(BaseMiddleware): 12 | def __init__(self): 13 | super().__init__() 14 | self.cache = TTLCache( 15 | maxsize=1000, 16 | ttl=60 * 60 * 6, # 6 часов 17 | ) 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: Dict[str, Any], 24 | ) -> Any: 25 | # Говорим IDE, что event на самом деле – Message 26 | event = cast(Message, event) 27 | user_id = event.from_user.id 28 | 29 | # Надо обновить данные пользователя, если он не в кэше 30 | if user_id not in self.cache: 31 | session: AsyncSession = data["session"] 32 | await upsert_user( 33 | session=session, 34 | telegram_id=event.from_user.id, 35 | first_name=event.from_user.first_name, 36 | last_name=event.from_user.last_name, 37 | ) 38 | self.cache[user_id] = None 39 | return await handler(event, data) 40 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/middlewares/track_all_users.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any, cast 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject, Message 5 | from cachetools import TTLCache 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from bot.db.requests import upsert_user 9 | 10 | 11 | class TrackAllUsersMiddleware(BaseMiddleware): 12 | def __init__(self): 13 | super().__init__() 14 | self.cache = TTLCache( 15 | maxsize=1000, 16 | ttl=60 * 60 * 6, # 6 часов 17 | ) 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: Dict[str, Any], 24 | ) -> Any: 25 | # Говорим IDE, что event на самом деле – Message 26 | event = cast(Message, event) 27 | user_id = event.from_user.id 28 | 29 | # Надо обновить данные пользователя, если он не в кэше 30 | if user_id not in self.cache: 31 | session: AsyncSession = data["session"] 32 | await upsert_user( 33 | session=session, 34 | telegram_id=event.from_user.id, 35 | first_name=event.from_user.first_name, 36 | last_name=event.from_user.last_name, 37 | ) 38 | self.cache[user_id] = None 39 | return await handler(event, data) 40 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/middlewares/track_all_users.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any, cast 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject, Message 5 | from cachetools import TTLCache 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from bot.db.requests import upsert_user 9 | 10 | 11 | class TrackAllUsersMiddleware(BaseMiddleware): 12 | def __init__(self): 13 | super().__init__() 14 | self.cache = TTLCache( 15 | maxsize=1000, 16 | ttl=60 * 60 * 6, # 6 часов 17 | ) 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: Dict[str, Any], 24 | ) -> Any: 25 | # Говорим IDE, что event на самом деле – Message 26 | event = cast(Message, event) 27 | user_id = event.from_user.id 28 | 29 | # Надо обновить данные пользователя, если он не в кэше 30 | if user_id not in self.cache: 31 | session: AsyncSession = data["session"] 32 | await upsert_user( 33 | session=session, 34 | telegram_id=event.from_user.id, 35 | first_name=event.from_user.first_name, 36 | last_name=event.from_user.last_name, 37 | ) 38 | self.cache[user_id] = None 39 | return await handler(event, data) 40 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/middlewares/track_all_users.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable, Dict, Any, cast 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject, Message 5 | from cachetools import TTLCache 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from bot.db.requests import upsert_user 9 | 10 | 11 | class TrackAllUsersMiddleware(BaseMiddleware): 12 | def __init__(self): 13 | super().__init__() 14 | self.cache = TTLCache( 15 | maxsize=1000, 16 | ttl=60 * 60 * 6, # 6 часов 17 | ) 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: Dict[str, Any], 24 | ) -> Any: 25 | # Говорим IDE, что event на самом деле – Message 26 | event = cast(Message, event) 27 | user_id = event.from_user.id 28 | 29 | # Надо обновить данные пользователя, если он не в кэше 30 | if user_id not in self.cache: 31 | session: AsyncSession = data["session"] 32 | await upsert_user( 33 | session=session, 34 | telegram_id=event.from_user.id, 35 | first_name=event.from_user.first_name, 36 | last_name=event.from_user.last_name, 37 | ) 38 | self.cache[user_id] = None 39 | return await handler(event, data) 40 | -------------------------------------------------------------------------------- /10_Template/bot/handling/middlewares/translator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict, Optional 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from fluentogram import TranslatorHub 6 | from structlog import get_logger 7 | 8 | 9 | class TranslatorRunnerMiddleware(BaseMiddleware): 10 | def __init__( 11 | self, 12 | translator_hub_alias: str = '_translator_hub', 13 | translator_runner_alias: str = 'i18n', 14 | ): 15 | self.translator_hub_alias = translator_hub_alias 16 | self.translator_runner_alias = translator_runner_alias 17 | self.logger = get_logger('TranslatorRunnerMiddleware') 18 | 19 | async def __call__( 20 | self, 21 | event_handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | ctx_data: Dict[str, Any], 24 | ) -> None: 25 | await self.logger.debug('TranslatorRunnerMiddleware begun') 26 | from_user = getattr(event, 'from_user', None) 27 | translator_hub: Optional[TranslatorHub] = ctx_data.get(self.translator_hub_alias) 28 | if from_user is None or translator_hub is None: 29 | return await event_handler(event, ctx_data) 30 | lang = from_user.language_code 31 | ctx_data[self.translator_runner_alias] = translator_hub.get_translator_by_locale(lang) 32 | await event_handler(event, ctx_data) 33 | await self.logger.debug('TranslatorRunnerMiddleware end') 34 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy import text 5 | from sqlalchemy.ext.asyncio import create_async_engine 6 | 7 | from bot.config_reader import get_config, BotConfig, DbConfig 8 | from bot.db.tables import metadata 9 | from bot.handlers import get_routers 10 | 11 | 12 | async def main(): 13 | # В get_config передаются два аргумента: 14 | # 1. Модель Pydantic, в которую будет преобразована часть конфига 15 | # 2. Корневой "ключ", из которого данные читаются и накладываются на модель 16 | db_config = get_config(DbConfig, "db") 17 | 18 | engine = create_async_engine( 19 | url=str(db_config.dsn), # здесь требуется приведение к строке 20 | echo=db_config.is_echo 21 | ) 22 | 23 | # Проверка соединения с СУБД 24 | async with engine.begin() as conn: 25 | await conn.execute(text("SELECT 1")) 26 | 27 | # Создание таблиц 28 | async with engine.begin() as conn: 29 | # Если ловите ошибку "таблица уже существует", 30 | # раскомментируйте следующую строку: 31 | # await conn.run_sync(metadata.drop_all) 32 | await conn.run_sync(metadata.create_all) 33 | 34 | dp = Dispatcher(db_engine=engine) 35 | dp.include_routers(*get_routers()) 36 | 37 | bot_config = get_config(BotConfig, "bot") 38 | bot = Bot(token=bot_config.token.get_secret_value()) 39 | 40 | print("Starting polling...") 41 | await dp.start_polling(bot) 42 | 43 | 44 | asyncio.run(main()) 45 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.in -o requirements.txt 3 | aiofiles==23.2.1 4 | # via aiogram 5 | aiogram==3.12.0 6 | # via -r requirements.in 7 | aiohappyeyeballs==2.4.0 8 | # via aiohttp 9 | aiohttp==3.10.5 10 | # via aiogram 11 | aiosignal==1.3.1 12 | # via aiohttp 13 | alembic==1.13.2 14 | # via -r requirements.in 15 | annotated-types==0.7.0 16 | # via pydantic 17 | attrs==24.2.0 18 | # via aiohttp 19 | cachetools==5.5.0 20 | # via -r requirements.in 21 | certifi==2024.7.4 22 | # via aiogram 23 | frozenlist==1.4.1 24 | # via 25 | # aiohttp 26 | # aiosignal 27 | greenlet==3.0.3 28 | # via sqlalchemy 29 | idna==3.7 30 | # via yarl 31 | magic-filter==1.0.12 32 | # via aiogram 33 | mako==1.3.5 34 | # via alembic 35 | markupsafe==2.1.5 36 | # via mako 37 | multidict==6.0.5 38 | # via 39 | # aiohttp 40 | # yarl 41 | psycopg==3.2.1 42 | # via -r requirements.in 43 | psycopg-binary==3.2.1 44 | # via -r requirements.in 45 | pydantic==2.8.2 46 | # via aiogram 47 | pydantic-core==2.20.1 48 | # via pydantic 49 | pyyaml==6.0.2 50 | # via -r requirements.in 51 | sqlalchemy==2.0.32 52 | # via 53 | # -r requirements.in 54 | # alembic 55 | typing-extensions==4.12.2 56 | # via 57 | # aiogram 58 | # alembic 59 | # psycopg 60 | # pydantic 61 | # pydantic-core 62 | # sqlalchemy 63 | yarl==1.9.4 64 | # via aiohttp 65 | -------------------------------------------------------------------------------- /10_Template/nats/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "nats-py" 5 | version = "2.9.0" 6 | description = "NATS client for Python" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "nats_py-2.9.0.tar.gz", hash = "sha256:01886eb9e0a87f0ec630652cf1fae65d2a8556378a609bc6cc07d2ea60c8d0dd"}, 12 | ] 13 | 14 | [package.extras] 15 | aiohttp = ["aiohttp"] 16 | fast-parse = ["fast-mail-parser"] 17 | nkeys = ["nkeys"] 18 | 19 | [[package]] 20 | name = "structlog" 21 | version = "24.4.0" 22 | description = "Structured Logging for Python" 23 | category = "main" 24 | optional = false 25 | python-versions = ">=3.8" 26 | files = [ 27 | {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, 28 | {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, 29 | ] 30 | 31 | [package.extras] 32 | dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] 33 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] 34 | tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] 35 | typing = ["mypy (>=1.4)", "rich", "twisted"] 36 | 37 | [metadata] 38 | lock-version = "2.0" 39 | python-versions = "^3.11" 40 | content-hash = "ac30287a30552e3bd895ea86a5b8a2914114eadfa5792d4a206674e94f8010f6" 41 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 5 | 6 | from bot.config_reader import parse_settings 7 | from bot.handlers import get_routers 8 | from bot.middlewares import DbSessionMiddleware 9 | from bot.db.requests import test_connection 10 | 11 | 12 | async def main(): 13 | # Получение настроек текущего приложения 14 | settings = parse_settings() 15 | 16 | # Создание асинхронного "движка" с указанием URL подключения 17 | engine = create_async_engine(url=str(settings.db_url), echo=True) 18 | # Создание пула сессий 19 | sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 20 | 21 | # Из пула извлекается одна сессия 22 | # и проверяется подключение к СУБД 23 | # Если связи нет, то код "упадёт". 24 | # Но это нормально, потому что ситуация критическая 25 | async with sessionmaker() as session: 26 | await test_connection(session) 27 | 28 | # Создание диспетчера aiogram 29 | dp = Dispatcher() 30 | # На тип Update (родительский тип всех видов апдейтов) 31 | # навешивается мидлварь, из которой будут пробрасываться сессии в хэндлеры 32 | dp.update.middleware(DbSessionMiddleware(session_pool=sessionmaker)) 33 | # Подключение цепочки роутеров к диспетчеру 34 | dp.include_routers(*get_routers()) 35 | 36 | # Создание объекта бота с токеном, полученным из настроек 37 | bot = Bot(token=settings.bot_token.get_secret_value()) 38 | 39 | print("Starting polling...") 40 | 41 | # Запуск поллинга 42 | await dp.start_polling(bot) 43 | 44 | 45 | if __name__ == '__main__': 46 | asyncio.run(main()) 47 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/tests/test_orders_flow_and_db.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Sequence 3 | 4 | import pytest 5 | from aiogram import Dispatcher 6 | from aiogram.enums import ChatType 7 | from aiogram.methods import SendMessage 8 | from aiogram.types import Message, Update, User, Chat 9 | from sqlalchemy import select 10 | from sqlalchemy.ext.asyncio import AsyncSession 11 | 12 | from bot.db.models import RegisteredUser, Order 13 | from tests.mocked_aiogram import MockedBot 14 | 15 | 16 | def make_message(user_id: int, text: str) -> Message: 17 | user = User(id=user_id, first_name="User", is_bot=False) 18 | chat = Chat(id=user_id, type=ChatType.PRIVATE) 19 | return Message(message_id=1, from_user=user, chat=chat, date=datetime.now(), text=text) 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_making_orders(dp: Dispatcher, bot: MockedBot, session: AsyncSession): 24 | user_id = 123456 25 | flow_messages = [ 26 | make_message(user_id, "/start"), 27 | make_message(user_id, "/food"), 28 | make_message(user_id, "Суши"), 29 | make_message(user_id, "Большую"), 30 | ] 31 | for message in flow_messages: 32 | bot.add_result_for(SendMessage, ok=True) 33 | await dp.feed_update(bot, Update(message=message, update_id=1)) 34 | 35 | stmt = select(RegisteredUser).where(RegisteredUser.telegram_id == user_id) 36 | user_response = await session.scalar(stmt) 37 | assert user_response is not None 38 | assert user_response.telegram_id == user_id 39 | 40 | order_stmt = select(Order).where(Order.telegram_id == user_id) 41 | orders: Sequence[Order] = (await session.scalars(order_stmt)).all() 42 | assert len(orders) == 1 43 | order = orders[0] 44 | assert order.order_contents == "большую порцию суши" 45 | -------------------------------------------------------------------------------- /10_Template/bot/handling/middlewares/database_repo.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict, Optional 2 | 3 | import structlog 4 | from aiogram import BaseMiddleware 5 | from aiogram.types import TelegramObject 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | 9 | class DatabaseMiddleware(BaseMiddleware): 10 | """Middleware which drops session into a context.""" 11 | 12 | def __init__( 13 | self, 14 | session_factory: Optional[sessionmaker | str], 15 | context_name: str = 'db', 16 | ) -> None: 17 | self.context_name = context_name 18 | if isinstance(session_factory, str): 19 | self.session_factory = None 20 | self.session_factory_ctx_name = session_factory 21 | else: 22 | self.session_factory = session_factory 23 | self.logger = structlog.getLogger() 24 | 25 | async def __call__( 26 | self, 27 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 28 | event: TelegramObject, 29 | ctx_data: Dict[str, Any], 30 | ) -> None: 31 | await self.logger.debug('DatabaseMiddleware begun') 32 | session_factory = self.session_factory or ctx_data.get( 33 | self.session_factory_ctx_name 34 | ) 35 | async with session_factory() as session: # type: ignore 36 | ctx_data[self.context_name] = session 37 | try: 38 | await handler(event, ctx_data) 39 | await session.commit() 40 | except Exception as exception: 41 | await session.rollback() 42 | raise exception 43 | ctx_data.pop(self.context_name, None) 44 | await self.logger.debug('DatabaseMiddleware end') 45 | -------------------------------------------------------------------------------- /10_Template/bot/handling/middlewares/logging.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Awaitable, Callable, Dict 3 | 4 | import structlog 5 | from aiogram import BaseMiddleware 6 | from aiogram.types import TelegramObject 7 | 8 | 9 | class LoggingMiddleware(BaseMiddleware): 10 | def __init__(self) -> None: 11 | self.logger = structlog.get_logger(self.__class__.__name__) 12 | 13 | async def __call__( 14 | self, 15 | event_handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 16 | event: TelegramObject, 17 | ctx_data: Dict[str, Any], 18 | ) -> None: 19 | start_time = datetime.now() 20 | structlog.contextvars.bind_contextvars( 21 | update=event.model_dump( 22 | exclude_unset=True, 23 | exclude_none=True, 24 | exclude_defaults=True, 25 | ), 26 | start_time=datetime.now(), 27 | ) 28 | try: 29 | await event_handler(event, ctx_data) 30 | except Exception as e: 31 | end_time = datetime.now() 32 | await self.logger.exception( 33 | 'Abnormal handling event detected, critical error happened', 34 | e, 35 | start_time=start_time, 36 | end_time=end_time, 37 | execution_time=(end_time - start_time).microseconds, 38 | ) 39 | else: 40 | end_time = datetime.now() 41 | execution_time = end_time - start_time 42 | await self.logger.info( 43 | f'Event successfully executed in {execution_time.microseconds} microseconds', 44 | execution_time=execution_time, 45 | ) 46 | finally: 47 | structlog.contextvars.clear_contextvars() 48 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/migrations/versions/001_initial_migration_created_tables.py: -------------------------------------------------------------------------------- 1 | """Initial migration, created tables 2 | 3 | Revision ID: 001 4 | Revises: 5 | Create Date: 2023-08-14 02:06:18.444546 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '001' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('registeredusers', 24 | sa.Column('telegram_id', sa.BIGINT(), nullable=False), 25 | sa.Column( 26 | 'registered_at', 27 | postgresql.TIMESTAMP(timezone=True), 28 | server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), 29 | nullable=False 30 | ), 31 | sa.PrimaryKeyConstraint('telegram_id') 32 | ) 33 | op.create_table('orders', 34 | sa.Column('order_id', sa.UUID(), nullable=False), 35 | sa.Column('telegram_id', sa.BIGINT(), nullable=False), 36 | sa.Column( 37 | 'created_at', 38 | postgresql.TIMESTAMP(timezone=True), 39 | server_default=sa.text("TIMEZONE('utc', CURRENT_TIMESTAMP)"), 40 | nullable=False 41 | ), 42 | sa.Column('order_contents', sa.TEXT(), nullable=False), 43 | sa.ForeignKeyConstraint(['telegram_id'], ['registeredusers.telegram_id'], ), 44 | sa.PrimaryKeyConstraint('order_id') 45 | ) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade() -> None: 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_table('orders') 52 | op.drop_table('registeredusers') 53 | # ### end Alembic commands ### -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/models.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from sqlalchemy import ForeignKey 4 | from sqlalchemy.dialects.postgresql import TIMESTAMP, TEXT, BIGINT, UUID 5 | from sqlalchemy.ext.compiler import compiles 6 | from sqlalchemy.orm import mapped_column, Mapped, relationship 7 | from sqlalchemy.sql import expression 8 | from sqlalchemy.types import DateTime 9 | 10 | from bot.db.base import Base 11 | 12 | 13 | class utcnow(expression.FunctionElement): 14 | type = DateTime() 15 | inherit_cache = True 16 | 17 | 18 | @compiles(utcnow, 'postgresql') 19 | def pg_utcnow(element, compiler, **kw): 20 | return "TIMEZONE('utc', CURRENT_TIMESTAMP)" 21 | 22 | 23 | class RegisteredUser(Base): 24 | __tablename__ = "registeredusers" 25 | 26 | telegram_id: Mapped[int] = mapped_column( 27 | BIGINT, 28 | primary_key=True 29 | ) 30 | registered_at: Mapped[int] = mapped_column( 31 | TIMESTAMP(timezone=True), 32 | nullable=False, 33 | server_default=utcnow() 34 | ) 35 | orders: Mapped[list["Order"]] = relationship( 36 | back_populates="telegram_user", 37 | cascade="all, delete-orphan" 38 | ) 39 | 40 | 41 | class Order(Base): 42 | __tablename__ = "orders" 43 | 44 | order_id: Mapped[UUID] = mapped_column( 45 | UUID(as_uuid=True), 46 | primary_key=True, 47 | default=uuid4 48 | ) 49 | telegram_id: Mapped[int] = mapped_column( 50 | ForeignKey("registeredusers.telegram_id") 51 | ) 52 | created_at: Mapped[int] = mapped_column( 53 | TIMESTAMP(timezone=True), 54 | nullable=False, 55 | server_default=utcnow() 56 | ) 57 | order_contents: Mapped[str] = mapped_column( 58 | TEXT, 59 | nullable=False 60 | ) 61 | telegram_user: Mapped["RegisteredUser"] = relationship( 62 | back_populates="orders" 63 | ) 64 | -------------------------------------------------------------------------------- /10_Template/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Profiles 2 | # 1) all - runs all 3 | # 2) without_bot - all but without bot 4 | # 3) infrastructure - run only infrastructure 5 | # 6 | # Usage `docker compose --profile all up` 7 | 8 | services: 9 | app: 10 | build: 11 | context: . 12 | dockerfile: Dockerfile 13 | command: ["poetry", "run", "python", "app.py"] 14 | depends_on: 15 | - worker 16 | profiles: 17 | - all 18 | 19 | worker: 20 | build: 21 | context: ./img-converter/ 22 | dockerfile: Dockerfile 23 | command: ["poetry", "run", "python", "app.py"] 24 | environment: 25 | NATS_URL: "nats://nats:4222" 26 | depends_on: 27 | - nats 28 | profiles: 29 | - without_bot 30 | - all 31 | 32 | nats-migrate: 33 | build: 34 | context: ./nats/ 35 | dockerfile: Dockerfile 36 | command: ["poetry", "run", "python", "migration.py"] 37 | restart: no 38 | environment: 39 | NATS_URL: "nats://nats:4222" 40 | volumes: 41 | - ./nats/data:/data 42 | - ./nats/nats.conf:/config/nats.conf 43 | depends_on: 44 | - nats 45 | profiles: 46 | - without_bot 47 | - infrastructure 48 | - all 49 | 50 | nats: 51 | image: nats:2.10.22 52 | restart: on-failure 53 | entrypoint: /nats-server 54 | command: "-c /config/nats.conf" 55 | ports: 56 | - "4222:4222" 57 | - "8222:8222" 58 | volumes: 59 | - ./nats/data:/data 60 | - ./nats/nats.conf:/config/nats.conf 61 | profiles: 62 | - without_bot 63 | - infrastructure 64 | - all 65 | 66 | postgres: 67 | image: 'postgres:17.2' 68 | ports: 69 | - 5432:5432 70 | environment: 71 | POSTGRES_USER: username 72 | POSTGRES_PASSWORD: password 73 | POSTGRES_DB: bot 74 | profiles: 75 | - without_bot 76 | - infrastructure 77 | - all 78 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy import text 5 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 6 | 7 | from bot.config_reader import get_config, BotConfig, DbConfig 8 | from bot.db import Base 9 | from bot.handlers import get_routers 10 | from bot.middlewares import DbSessionMiddleware, TrackAllUsersMiddleware 11 | 12 | 13 | async def main(): 14 | # В get_config передаются два аргумента: 15 | # 1. Модель Pydantic, в которую будет преобразована часть конфига 16 | # 2. Корневой "ключ", из которого данные читаются и накладываются на модель 17 | db_config = get_config(DbConfig, "db") 18 | 19 | engine = create_async_engine( 20 | url=str(db_config.dsn), # здесь требуется приведение к строке 21 | echo=db_config.is_echo 22 | ) 23 | 24 | # Проверка соединения с СУБД 25 | async with engine.begin() as conn: 26 | await conn.execute(text("SELECT 1")) 27 | 28 | # Создание таблиц 29 | async with engine.begin() as connection: 30 | # Если ловите ошибку "таблица уже существует", 31 | # раскомментируйте следующую строку: 32 | # await connection.run_sync(Base.metadata.drop_all) 33 | await connection.run_sync(Base.metadata.create_all) 34 | 35 | bot_config = get_config(BotConfig, "bot") 36 | 37 | # Создание диспетчера 38 | dp = Dispatcher(admin_id=bot_config.admin_id) 39 | 40 | # Подключение мидлварей 41 | Sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 42 | dp.update.outer_middleware(DbSessionMiddleware(Sessionmaker)) 43 | dp.message.outer_middleware(TrackAllUsersMiddleware()) 44 | 45 | dp.include_routers(*get_routers()) 46 | bot = Bot(token=bot_config.token.get_secret_value()) 47 | 48 | print("Starting polling...") 49 | await dp.start_polling(bot) 50 | 51 | 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/before_alembic/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy import text 5 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 6 | 7 | from bot.config_reader import get_config, BotConfig, DbConfig 8 | from bot.db.base import Base 9 | from bot.handlers import get_routers 10 | from bot.middlewares import DbSessionMiddleware, TrackAllUsersMiddleware 11 | 12 | 13 | async def main(): 14 | # В get_config передаются два аргумента: 15 | # 1. Модель Pydantic, в которую будет преобразована часть конфига 16 | # 2. Корневой "ключ", из которого данные читаются и накладываются на модель 17 | db_config = get_config(DbConfig, "db") 18 | 19 | engine = create_async_engine( 20 | url=str(db_config.dsn), # здесь требуется приведение к строке 21 | echo=db_config.is_echo 22 | ) 23 | 24 | # Проверка соединения с СУБД 25 | async with engine.begin() as conn: 26 | await conn.execute(text("SELECT 1")) 27 | 28 | # Создание таблиц 29 | async with engine.begin() as connection: 30 | # Если ловите ошибку "таблица уже существует", 31 | # раскомментируйте следующую строку: 32 | # await connection.run_sync(Base.metadata.drop_all) 33 | await connection.run_sync(Base.metadata.create_all) 34 | 35 | # Создание диспетчера 36 | dp = Dispatcher(db_engine=engine) 37 | 38 | # Подключение мидлварей 39 | Sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 40 | dp.update.outer_middleware(DbSessionMiddleware(Sessionmaker)) 41 | dp.message.outer_middleware(TrackAllUsersMiddleware()) 42 | 43 | dp.include_routers(*get_routers()) 44 | 45 | bot_config = get_config(BotConfig, "bot") 46 | bot = Bot(token=bot_config.token.get_secret_value()) 47 | 48 | print("Starting polling...") 49 | await dp.start_polling(bot) 50 | 51 | 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy import text 5 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 6 | 7 | from bot.config_reader import get_config, BotConfig, DbConfig 8 | from bot.db.base import Base 9 | from bot.handlers import get_routers 10 | from bot.middlewares import DbSessionMiddleware, TrackAllUsersMiddleware 11 | 12 | 13 | async def main(): 14 | # В get_config передаются два аргумента: 15 | # 1. Модель Pydantic, в которую будет преобразована часть конфига 16 | # 2. Корневой "ключ", из которого данные читаются и накладываются на модель 17 | db_config = get_config(DbConfig, "db") 18 | 19 | engine = create_async_engine( 20 | url=str(db_config.dsn), # здесь требуется приведение к строке 21 | echo=db_config.is_echo 22 | ) 23 | 24 | # Проверка соединения с СУБД 25 | async with engine.begin() as conn: 26 | await conn.execute(text("SELECT 1")) 27 | 28 | # Создание таблиц 29 | async with engine.begin() as connection: 30 | # Если ловите ошибку "таблица уже существует", 31 | # раскомментируйте следующую строку: 32 | # await connection.run_sync(Base.metadata.drop_all) 33 | await connection.run_sync(Base.metadata.create_all) 34 | 35 | # Создание диспетчера 36 | dp = Dispatcher(db_engine=engine) 37 | 38 | # Подключение мидлварей 39 | Sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 40 | dp.update.outer_middleware(DbSessionMiddleware(Sessionmaker)) 41 | dp.message.outer_middleware(TrackAllUsersMiddleware()) 42 | 43 | dp.include_routers(*get_routers()) 44 | 45 | bot_config = get_config(BotConfig, "bot") 46 | bot = Bot(token=bot_config.token.get_secret_value()) 47 | 48 | print("Starting polling...") 49 | await dp.start_polling(bot) 50 | 51 | 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiogram import Bot, Dispatcher 4 | from sqlalchemy import text 5 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 6 | 7 | from bot.config_reader import get_config, BotConfig, DbConfig 8 | from bot.db.base import Base 9 | from bot.handlers import get_routers 10 | from bot.middlewares import DbSessionMiddleware, TrackAllUsersMiddleware 11 | 12 | 13 | async def main(): 14 | # В get_config передаются два аргумента: 15 | # 1. Модель Pydantic, в которую будет преобразована часть конфига 16 | # 2. Корневой "ключ", из которого данные читаются и накладываются на модель 17 | db_config = get_config(DbConfig, "db") 18 | 19 | engine = create_async_engine( 20 | url=str(db_config.dsn), # здесь требуется приведение к строке 21 | echo=db_config.is_echo 22 | ) 23 | 24 | # Проверка соединения с СУБД 25 | async with engine.begin() as conn: 26 | await conn.execute(text("SELECT 1")) 27 | 28 | # Создание таблиц 29 | async with engine.begin() as connection: 30 | # Если ловите ошибку "таблица уже существует", 31 | # раскомментируйте следующую строку: 32 | # await connection.run_sync(Base.metadata.drop_all) 33 | await connection.run_sync(Base.metadata.create_all) 34 | 35 | # Создание диспетчера 36 | dp = Dispatcher(db_engine=engine) 37 | 38 | # Подключение мидлварей 39 | Sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 40 | dp.update.outer_middleware(DbSessionMiddleware(Sessionmaker)) 41 | dp.message.outer_middleware(TrackAllUsersMiddleware()) 42 | 43 | dp.include_routers(*get_routers()) 44 | 45 | bot_config = get_config(BotConfig, "bot") 46 | bot = Bot(token=bot_config.token.get_secret_value()) 47 | 48 | print("Starting polling...") 49 | await dp.start_polling(bot) 50 | 51 | 52 | asyncio.run(main()) 53 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/test_capybara_handlers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from aiogram.dispatcher.event.bases import UNHANDLED 5 | from aiogram.enums import ChatType 6 | from aiogram.methods import SendPhoto 7 | from aiogram.methods.base import TelegramType 8 | from aiogram.types import Update, Chat, User, Message, FSInputFile 9 | 10 | 11 | def predefined_random(): 12 | return 1 13 | 14 | 15 | def make_incoming_message() -> Message: 16 | """ 17 | Генерирует текстовое сообщение с командой /capybara от юзера к боту 18 | :return: объект Message с текстовой командой /capybara 19 | """ 20 | return Message( 21 | message_id=1, 22 | chat=Chat(id=123456, type=ChatType.PRIVATE), 23 | from_user=User(id=123456, is_bot=False, first_name="User"), 24 | date=datetime.now(), 25 | text="/capybara" 26 | ) 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_capybara_cmd(dp, bot, monkeypatch): 31 | monkeypatch.setattr( 32 | "bot.handlers.capybara_handlers.choose_random_image", 33 | predefined_random 34 | ) 35 | 36 | bot.add_result_for( 37 | method=SendPhoto, 38 | ok=True, 39 | # result сейчас не нужен 40 | ) 41 | 42 | # Отправка сообщения с командой /capybara 43 | update = await dp.feed_update( 44 | bot, 45 | Update(message=make_incoming_message(), update_id=1) 46 | ) 47 | 48 | # Проверка, что сообщение обработано 49 | assert update is not UNHANDLED 50 | 51 | # Получение отправленного ботом сообщения 52 | outgoing_message: TelegramType = bot.get_request() 53 | # Проверка различных свойств этого сообщения 54 | assert isinstance(outgoing_message, SendPhoto) 55 | assert outgoing_message.caption == "Случайная капибара" 56 | assert isinstance(outgoing_message.photo, FSInputFile) 57 | assert outgoing_message.photo.path == '/opt/images/capybara_1.jpg' 58 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/requests.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | 4 | from bot.db.models import RegisteredUser, Order 5 | 6 | 7 | async def get_user_by_id(session: AsyncSession, user_id: int) -> RegisteredUser | None: 8 | """ 9 | Получает пользователя по его айди. 10 | :param session: объект AsyncSession 11 | :param user_id: айди пользователя 12 | :return: объект RegisteredUser или None 13 | """ 14 | stmt = select(RegisteredUser).where(RegisteredUser.telegram_id == user_id) 15 | return await session.scalar(stmt) 16 | 17 | 18 | async def ensure_user(session: AsyncSession, user_id: int) -> None: 19 | """ 20 | Создаёт пользователя, если его раньше не было 21 | :param session: объект AsyncSession 22 | :param user_id: айди пользователя 23 | """ 24 | existing_user = await get_user_by_id(session, user_id) 25 | if existing_user is not None: 26 | return 27 | user = RegisteredUser(telegram_id=user_id) 28 | session.add(user) 29 | await session.commit() 30 | 31 | 32 | async def create_order(session: AsyncSession, fsm_data: dict, user_id: int) -> None: 33 | """ 34 | Создаёт заказ в СУБД с привязкой к пользователю 35 | :param session: объект AsyncSession 36 | :param fsm_data: данные из FSM с информацией о заказе 37 | :param user_id: айди пользователя 38 | """ 39 | 40 | def get_order_text(data: dict): 41 | return f"{data['chosen_size']} порцию {data['chosen_food']}" 42 | 43 | await ensure_user(session, user_id) 44 | order = Order( 45 | telegram_id=user_id, 46 | order_contents=get_order_text(fsm_data) 47 | ) 48 | session.add(order) 49 | await session.commit() 50 | 51 | 52 | async def test_connection(session: AsyncSession): 53 | """ 54 | Проверка соединения с СУБД 55 | :param session: объект AsyncSession 56 | """ 57 | stmt = select(1) 58 | return await session.scalar(stmt) 59 | -------------------------------------------------------------------------------- /10_Template/logs/startup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import structlog 4 | import orjson 5 | from structlog import processors 6 | from structlog.processors import JSONRenderer 7 | 8 | from logs.config import Config, LogsRenderer 9 | 10 | 11 | class AsyncBindableLogger(structlog.types.BindableLogger, structlog.stdlib.AsyncBoundLogger): # type: ignore 12 | """Type fix for AsyncBoundLogger.""" 13 | 14 | 15 | def startup(config: Config) -> None: 16 | pre_chain = [ 17 | structlog.contextvars.merge_contextvars, 18 | structlog.processors.add_log_level, 19 | structlog.processors.StackInfoRenderer(), 20 | structlog.dev.set_exc_info, 21 | structlog.processors.TimeStamper(fmt=config.time_format, utc=config.utc), 22 | ] 23 | 24 | handler = logging.StreamHandler() 25 | handler.set_name('default') 26 | handler.setLevel(config.level) 27 | 28 | if config.call_site: 29 | pre_chain.append(processors.CallsiteParameterAdder()) 30 | 31 | if config.renderer == LogsRenderer.text: 32 | renderer = structlog.dev.ConsoleRenderer(colors=True) 33 | elif config.renderer == LogsRenderer.json: 34 | renderer = JSONRenderer( 35 | serializer=lambda data: orjson.dumps(data).decode() 36 | # serializer=json.dumps 37 | ) 38 | else: 39 | raise ValueError('Logging: Unknown renderer set') 40 | 41 | formatter = structlog.stdlib.ProcessorFormatter( 42 | processors=[ 43 | structlog.stdlib.ProcessorFormatter.remove_processors_meta, 44 | renderer, 45 | ], 46 | foreign_pre_chain=pre_chain, # type: ignore 47 | ) 48 | handler.setFormatter(formatter) 49 | 50 | logging.basicConfig(handlers=(handler,), level=config.level) 51 | structlog.configure( 52 | processors=[ 53 | *pre_chain, # type: ignore 54 | structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 55 | ], 56 | logger_factory=structlog.stdlib.LoggerFactory(), 57 | wrapper_class=AsyncBindableLogger, 58 | cache_logger_on_first_use=True, 59 | ) 60 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/test_generate_handlers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from aiogram.dispatcher.event.bases import UNHANDLED 5 | from aiogram.enums import ChatType 6 | from aiogram.methods import SendMessage 7 | from aiogram.methods.base import TelegramType 8 | from aiogram.types import Update, Chat, User, Message 9 | 10 | user_id = 123456 11 | 12 | 13 | def make_incoming_message() -> Message: 14 | """ 15 | Генерирует текстовое сообщение с командой /generate от юзера к боту 16 | :return: объект Message с текстовой командой /generate 17 | """ 18 | return Message( 19 | message_id=1, 20 | chat=Chat(id=user_id, type=ChatType.PRIVATE), 21 | from_user=User(id=user_id, is_bot=False, first_name="User"), 22 | date=datetime.now(), 23 | text="/generate" 24 | ) 25 | 26 | 27 | async def override_generate_text(): 28 | return "тестовый текст" 29 | 30 | 31 | @pytest.mark.asyncio 32 | @pytest.mark.parametrize( 33 | ["raise_exception"], 34 | [ 35 | [False], 36 | [True] 37 | ] 38 | ) 39 | async def test_cmd_generate(dp, bot, monkeypatch, raise_exception): 40 | monkeypatch.setattr( 41 | "bot.handlers.generate_handlers.generate_text", 42 | override_generate_text 43 | ) 44 | # В зависимости от raise_exception возвращается успех (http 200) или неуспех (http 403) 45 | bot.add_result_for( 46 | method=SendMessage, 47 | ok=(not raise_exception), 48 | error_code=403 if raise_exception else 200 49 | # result сейчас не нужен 50 | ) 51 | 52 | # Отправка сообщения с командой /generate 53 | update = await dp.feed_update( 54 | bot, 55 | Update(message=make_incoming_message(), update_id=1) 56 | ) 57 | 58 | # Проверка, что сообщение обработано 59 | assert update is not UNHANDLED 60 | # Получение отправленного ботом сообщения 61 | outgoing_message: TelegramType = bot.get_request() 62 | # Проверка различных свойств этого сообщения 63 | assert isinstance(outgoing_message, SendMessage) 64 | assert outgoing_message.text == "тестовый текст" 65 | -------------------------------------------------------------------------------- /08_Databases/01-sqlalchemy-core/bot/handlers/commands.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart, Command 3 | from aiogram.types import Message 4 | from sqlalchemy import insert, delete, select, column 5 | from sqlalchemy.dialects.postgresql import insert 6 | from sqlalchemy.ext.asyncio.engine import AsyncEngine 7 | 8 | from bot.db.tables import users as users_table 9 | 10 | router = Router(name="commands router") 11 | 12 | 13 | @router.message(CommandStart()) 14 | async def cmd_start( 15 | message: Message, 16 | db_engine: AsyncEngine 17 | ): 18 | stmt = insert(users_table).values( 19 | telegram_id=message.from_user.id, 20 | first_name=message.from_user.first_name, 21 | last_name=message.from_user.last_name 22 | ) 23 | do_ignore = stmt.on_conflict_do_nothing(index_elements=['telegram_id']) 24 | async with db_engine.connect() as conn: 25 | await conn.execute(do_ignore) 26 | await conn.commit() 27 | await message.answer("Привет!") 28 | 29 | 30 | @router.message(Command("select")) 31 | async def cmd_select( 32 | message: Message, 33 | db_engine: AsyncEngine 34 | ): 35 | stmts = [ 36 | select(column("telegram_id"), column("first_name")).select_from(users_table), 37 | select("*").select_from(users_table), 38 | select("*").select_from(users_table).where(users_table.c.first_name == "Groosha"), 39 | select(users_table.c.telegram_id, users_table.c.first_name).select_from(users_table), 40 | select(users_table.c.telegram_id).where(users_table.c.telegram_id < 1_000_000) 41 | ] 42 | 43 | async with db_engine.connect() as conn: 44 | for stmt in stmts: 45 | result = await conn.execute(stmt) 46 | for row in result: 47 | print(row) 48 | print("==========") 49 | await message.answer("Проверьте терминал, чтобы увидеть данные.") 50 | 51 | 52 | @router.message(Command("deleteme")) 53 | async def cmd_deleteme( 54 | message: Message, 55 | db_engine: AsyncEngine 56 | ): 57 | stmt = ( 58 | delete(users_table) 59 | .where(users_table.c.telegram_id == message.from_user.id) 60 | ) 61 | async with db_engine.connect() as conn: 62 | await conn.execute(stmt) 63 | await conn.commit() 64 | await message.answer("Ваши данные удалены.") 65 | -------------------------------------------------------------------------------- /10_Template/bot/nats_storage/entry.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from typing import Any, Dict, Optional 4 | 5 | import structlog 6 | from aiogram.fsm.state import State 7 | from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey 8 | from nats.js.errors import KeyNotFoundError, NotFoundError 9 | from nats.js.kv import KeyValue 10 | 11 | 12 | class NATSFSMStorage(BaseStorage): 13 | def __init__( 14 | self, 15 | kv_states: KeyValue, 16 | kv_data: KeyValue, 17 | serializer=json.dumps, 18 | deserializer=json.loads, 19 | ): 20 | super().__init__() 21 | self.kv_states = kv_states 22 | self.kv_data = kv_data 23 | self.serializer = serializer 24 | self.deserializer = deserializer 25 | self.logger = structlog.get_logger(__name__) 26 | 27 | @staticmethod 28 | def _key_formatter(key: StorageKey) -> str: 29 | return ( 30 | ( 31 | f'{key.bot_id}.{key.user_id}.{key.chat_id}.{key.destiny}' 32 | + (f'.{key.thread_id}' if key.thread_id else '') 33 | ) 34 | .replace(':', '.') 35 | .rstrip('.') 36 | ) 37 | 38 | async def set_state(self, key: StorageKey, state: StateType = None) -> None: 39 | state = state.state if isinstance(state, State) else state 40 | ser_state = self.serializer(state or None) 41 | await self.kv_states.put(self._key_formatter(key), ser_state.encode()) 42 | 43 | async def get_state(self, key: StorageKey) -> Optional[str]: 44 | try: 45 | entry = await self.kv_states.get(self._key_formatter(key)) 46 | data = self.deserializer(entry.value) 47 | except NotFoundError: 48 | return None 49 | return data 50 | 51 | async def set_data(self, key: StorageKey, data: Dict[str, Any]) -> None: 52 | await self.kv_data.put(self._key_formatter(key), self.serializer(data) if data else b'') 53 | 54 | async def get_data(self, key: StorageKey) -> Dict[str, Any]: 55 | try: 56 | entry = await self.kv_data.get(self._key_formatter(key)) 57 | if entry.value is None: 58 | return {} 59 | return self.deserializer(entry.value) 60 | except KeyNotFoundError: 61 | return {} 62 | 63 | async def close(self) -> None: 64 | await asyncio.gather(self.kv_data.purge_deletes(),self.kv_states.purge_deletes()) 65 | return None 66 | -------------------------------------------------------------------------------- /10_Template/bot/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Awaitable, Callable 3 | 4 | import nats 5 | import orjson 6 | import structlog 7 | from aiogram import Bot, Dispatcher 8 | from aiogram.client.session.aiohttp import AiohttpSession 9 | from fluentogram import TranslatorHub 10 | from nats.js.kv import KeyValue 11 | 12 | from I18N import i18n_factory 13 | from bot.config import BotConfig 14 | from bot.handling import schema 15 | from bot.nats_storage import NATSFSMStorage 16 | from bot.send_done_photos import run 17 | 18 | 19 | def bot_factory(config: BotConfig) -> Bot: 20 | return Bot( 21 | config.token.get_secret_value(), 22 | session=AiohttpSession( 23 | json_dumps=lambda data: orjson.dumps(data).decode(), 24 | json_loads=orjson.loads, 25 | ), 26 | ) 27 | 28 | 29 | async def dispatcher_factory(kv_states: KeyValue, kv_data: KeyValue) -> Dispatcher: 30 | return Dispatcher( 31 | storage=NATSFSMStorage( 32 | kv_states, kv_data, serializer=orjson.dumps, deserializer=orjson.loads 33 | ) 34 | ) 35 | 36 | async def main( 37 | config: BotConfig, 38 | nats_address: str, 39 | session_maker, 40 | _bot_factory: Callable[[BotConfig], Bot] = bot_factory, 41 | _dispatcher_factory: Callable[[KeyValue, KeyValue], Awaitable[Dispatcher]] = dispatcher_factory, 42 | _i18n_factory: Callable[[], TranslatorHub] = i18n_factory, 43 | ) -> None: 44 | logger = structlog.get_logger(__name__) 45 | 46 | nc = await nats.connect(nats_address) 47 | js = nc.jetstream() 48 | await logger.debug('NATS connection established') 49 | 50 | bot = _bot_factory(config) 51 | kv_states = await js.key_value(config.fsm.states_bucket) 52 | kv_data = await js.key_value(config.fsm.data_bucket) 53 | await logger.debug('Bot and KV FSM buckets initialized') 54 | 55 | dp = await schema.assemble(_dispatcher_factory(kv_states, kv_data)) 56 | 57 | bot_representation = await bot.me() 58 | await logger.info(f'Bot {bot_representation.first_name} is ready to serve requests') 59 | try: 60 | await asyncio.gather(dp.start_polling( 61 | bot, 62 | _translator_hub=_i18n_factory(), 63 | nc=nc, 64 | _db_session_maker=session_maker, 65 | ), 66 | run(bot, nats_address) 67 | ) 68 | finally: 69 | await nc.close() 70 | -------------------------------------------------------------------------------- /10_Template/bot/send_done_photos.py: -------------------------------------------------------------------------------- 1 | import io 2 | from typing import Annotated 3 | 4 | from aiogram import Bot 5 | from aiogram.types import BufferedInputFile 6 | from faststream import context, Context, FastStream, Logger 7 | from faststream.nats import JStream, NatsBroker, NatsMessage, PullSub, NatsRouter 8 | from nats.js.api import DeliverPolicy 9 | from nats.js.errors import ObjectNotFoundError 10 | 11 | from bot.payload.convert_task import Task 12 | 13 | router = NatsRouter() 14 | 15 | stream = JStream(name="KV_watermarker-done-tasks", declare=False) 16 | 17 | @router.subscriber( 18 | "$KV.watermarker-done-tasks.{img_uuid}", 19 | stream=stream, 20 | deliver_policy=DeliverPolicy("new"), 21 | durable="True", 22 | pull_sub=PullSub(batch_size=10), 23 | retry=True, 24 | filter=lambda msg: msg.headers.get("KV-Operation") is None, 25 | ) 26 | 27 | async def handler(task: Task, logger: Logger, msg: NatsMessage, broker: Annotated[NatsBroker, Context("broker")], bot: Bot = Context()): 28 | logger.info(task.img_uuid) 29 | 30 | # todo Unresolved reference broker 31 | images = await broker.object_storage("watermarker-images", declare=False) 32 | tasks = await broker.key_value("watermarker-tasks", declare=False) 33 | done_tasks = await broker.key_value("watermarker-done-tasks", declare=False) 34 | 35 | buf = io.BytesIO() 36 | await images.get("out-" + str(task.img_uuid), buf) 37 | await done_tasks.delete(str(task.img_uuid)) 38 | await tasks.delete(str(task.img_uuid)) 39 | try: 40 | await images.delete(str(task.img_uuid)) 41 | except ObjectNotFoundError: 42 | pass 43 | await bot.send_photo(task.chat_id, 44 | photo=BufferedInputFile( 45 | file=buf.getvalue(), 46 | filename=task.watermark 47 | ) 48 | ) 49 | 50 | @router.subscriber( 51 | "$KV.watermarker-done-tasks.{img_uuid}", 52 | stream=stream, 53 | deliver_policy=DeliverPolicy("new"), 54 | durable="True", 55 | pull_sub=PullSub(batch_size=10), 56 | retry=True, 57 | filter=lambda msg: msg.headers.get("KV-Operation") is not None, 58 | ) 59 | async def trash(data: bytes, msg: NatsMessage): 60 | await msg.ack() 61 | 62 | async def run(bot: Bot, nats_address: str): 63 | broker = NatsBroker(nats_address) 64 | app = FastStream(broker) 65 | broker.include_router(router) 66 | context.set_global("bot", bot) # Globals is shit! 67 | await app.run() 68 | -------------------------------------------------------------------------------- /10_Template/bot/handling/dialogs/watermark.py: -------------------------------------------------------------------------------- 1 | import io 2 | import uuid 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram.enums import ContentType 6 | from aiogram.types import Message 7 | from aiogram_dialog import Dialog, DialogManager, Window 8 | from aiogram_dialog.widgets.input import MessageInput, TextInput 9 | from aiogram_dialog.widgets.kbd import Next 10 | from aiogram_dialog.widgets.text import Format 11 | from fluentogram import TranslatorRunner 12 | from nats.js import JetStreamContext 13 | from structlog import get_logger 14 | 15 | from bot.handling.states import Watermark 16 | from bot.payload.convert_task import Task 17 | 18 | logger = get_logger(__name__) 19 | 20 | 21 | async def getter(dialog_manager: DialogManager, i18n: TranslatorRunner, **kwargs): 22 | await logger.debug('main page getter called') 23 | return { 24 | 'enter_image': i18n.enter_image(), 25 | 'enter_watermark': i18n.enter_watermark(), 26 | } 27 | 28 | async def document_handler( 29 | message: Message, 30 | message_input: MessageInput, 31 | manager: DialogManager, 32 | ): 33 | nc = manager.middleware_data["nc"] 34 | js: JetStreamContext = nc.jetstream() 35 | tasks = await js.key_value("watermarker-tasks") 36 | watermark = manager.find("watermark").get_value() 37 | while True: 38 | try: 39 | photo_uuid = uuid.uuid4() 40 | task = Task(chat_id=message.chat.id, img_uuid=photo_uuid, img_format="JPEG", watermark=watermark) 41 | await tasks.create(str(photo_uuid), task.model_dump_json().encode()) 42 | break 43 | finally: 44 | pass 45 | obj = await js.object_store("watermarker-images") 46 | photo_io = io.BytesIO() 47 | photo_id = message.photo.pop().file_id 48 | await message.bot.download(photo_id, destination=photo_io) 49 | await obj.put("in-" + str(photo_uuid), photo_io.getvalue()) 50 | print(manager.dialog_data) 51 | i18n = manager.middleware_data.get("i18n") 52 | await message.answer(i18n.in_progress()) 53 | await manager.done() 54 | 55 | dialog = Dialog( 56 | Window( 57 | Format('{enter_watermark}'), 58 | TextInput(id="watermark", on_success=Next()), 59 | state=Watermark.enter_text, 60 | getter=getter, 61 | ), 62 | Window( 63 | Format('{enter_image}'), 64 | MessageInput(document_handler, content_types=[ContentType.PHOTO]), 65 | state=Watermark.enter_photo, 66 | getter=getter, 67 | ) 68 | ) 69 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/test_calculator.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from aiogram import Dispatcher 5 | from aiogram.enums import ChatType 6 | from aiogram.fsm.context import StorageKey, FSMContext 7 | from aiogram.methods import SendMessage 8 | from aiogram.methods.base import TelegramType 9 | from aiogram.types import Message, Update, User, Chat 10 | 11 | from bot.states import CalculatorStates 12 | from tests.mocked_aiogram import MockedBot 13 | 14 | user_id = 123456 15 | 16 | 17 | def make_message(text: str) -> Message: 18 | user = User(id=user_id, first_name="User", is_bot=False) 19 | chat = Chat(id=user_id, type=ChatType.PRIVATE) 20 | return Message( 21 | message_id=1, 22 | from_user=user, 23 | chat=chat, 24 | date=datetime.now(), 25 | text=text 26 | ) 27 | 28 | 29 | @pytest.mark.asyncio 30 | @pytest.mark.parametrize( 31 | "num1, num2, operation, expected_text", 32 | [ 33 | [1, 1, "+", "Ответ: 2"], 34 | [6, 2, "-", "Ответ: 4"], 35 | [7, 8, "*", "Ответ: 56"], 36 | [8, 0, "/", "На ноль делить нельзя!"], 37 | [8, 9, "/", "Ответ: 0.89"], 38 | ] 39 | ) 40 | async def test_calc( 41 | dp: Dispatcher, 42 | bot: MockedBot, 43 | num1: int, 44 | num2: int, 45 | operation: str, 46 | expected_text: str 47 | ): 48 | # Подготовка нужного стейта и данных с ним 49 | fsm_context: FSMContext = dp.fsm.get_context(bot=bot, user_id=user_id, chat_id=user_id) 50 | await fsm_context.set_state(CalculatorStates.choosing_operation) 51 | await fsm_context.set_data({"num1": num1, "num2": num2}) 52 | 53 | # Альтернативный вариант 54 | # fsm_storage_key = StorageKey(bot_id=bot.id, user_id=user_id, chat_id=user_id) 55 | # await dp.storage.set_data(fsm_storage_key, {"num1": num1, "num2": num2}) 56 | # await dp.storage.set_state(fsm_storage_key, CalculatorState.choosing_operation) 57 | 58 | bot.add_result_for(SendMessage, ok=True) 59 | await dp.feed_update( 60 | bot, 61 | Update( 62 | message=make_message(operation), 63 | update_id=1 64 | ) 65 | ) 66 | 67 | # Получение отправленного ботом сообщения 68 | outgoing_message: TelegramType = bot.get_request() 69 | # Проверка типа сообщения 70 | assert isinstance(outgoing_message, SendMessage) 71 | # Дополнительные проверки содержимого сообщения 72 | assert outgoing_message.text == expected_text 73 | assert outgoing_message.reply_markup is not None 74 | assert outgoing_message.reply_markup.remove_keyboard is True 75 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/migrations/versions/20240902_0221_added_expiration_date_column_to_.py: -------------------------------------------------------------------------------- 1 | """Added expiration_date column to licenses table 2 | 3 | Revision ID: 79ba75fa301b 4 | Revises: ab57f3c60220 5 | Create Date: 2024-09-02 02:21:57.755968 6 | 7 | """ 8 | from datetime import datetime, timedelta, UTC 9 | from random import randint 10 | from typing import Sequence, Union 11 | 12 | from alembic import op 13 | from sqlalchemy import BigInteger, String, DateTime, Column, select 14 | from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase 15 | from sqlalchemy.orm.session import Session 16 | 17 | # revision identifiers, used by Alembic. 18 | revision: str = '79ba75fa301b' 19 | down_revision: Union[str, None] = 'ab57f3c60220' 20 | branch_labels: Union[str, Sequence[str], None] = None 21 | depends_on: Union[str, Sequence[str], None] = None 22 | 23 | 24 | class Base(DeclarativeBase): 25 | pass 26 | 27 | 28 | class License(Base): 29 | __tablename__ = "licenses" 30 | 31 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 32 | email: Mapped[str] = mapped_column(String, nullable=False) 33 | key: Mapped[str] = mapped_column(String, nullable=False) 34 | expiration_date: Mapped[datetime] = mapped_column( 35 | DateTime, nullable=True 36 | ) 37 | 38 | 39 | def upgrade() -> None: 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.add_column( 42 | 'licenses', 43 | Column('expiration_date', DateTime(), nullable=True) 44 | ) 45 | 46 | # Создадим сессию и получим все объекты вышеописанной 47 | # модели License из базы. 48 | session = Session(bind=op.get_bind()) 49 | statement = select(License) 50 | rows = session.scalars(statement).all() 51 | 52 | # Если у вас версия Python ниже 3.12, то вместо datetime.now(UTC) 53 | # можно использовать datetime.utcnow() 54 | now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) 55 | 56 | # Теперь для каждой записи зададим произвольное значение 57 | # expiration_date от 10 до 40 дней в будущем. 58 | row: License 59 | for row in rows: 60 | days_in_future = timedelta(days=randint(10,40)) 61 | row.expiration_date = now + days_in_future 62 | session.commit() 63 | 64 | # Снова включаем запрет на NULL для столбца expiration_date 65 | op.alter_column('licenses', 'expiration_date', nullable=False) 66 | # ### end Alembic commands ### 67 | 68 | 69 | def downgrade() -> None: 70 | # ### commands auto generated by Alembic - please adjust! ### 71 | op.drop_column('licenses', 'expiration_date') 72 | # ### end Alembic commands ### 73 | -------------------------------------------------------------------------------- /08_Databases/02-sqlalchemy-orm/bot/db/requests.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from sqlalchemy import select 4 | from sqlalchemy.dialects.postgresql import insert as upsert 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from sqlalchemy.orm import selectinload, joinedload 7 | 8 | from bot.db.models import User, Game 9 | 10 | 11 | async def upsert_user( 12 | session: AsyncSession, 13 | telegram_id: int, 14 | first_name: str, 15 | last_name: str | None = None, 16 | ): 17 | """ 18 | Добавление или обновление пользователя 19 | в таблице users 20 | :param session: сессия СУБД 21 | :param telegram_id: айди пользователя 22 | :param first_name: имя пользователя 23 | :param last_name: фамилия пользователя 24 | """ 25 | stmt = upsert(User).values( 26 | { 27 | "telegram_id": telegram_id, 28 | "first_name": first_name, 29 | "last_name": last_name, 30 | } 31 | ) 32 | stmt = stmt.on_conflict_do_update( 33 | index_elements=['telegram_id'], 34 | set_=dict( 35 | first_name=first_name, 36 | last_name=last_name, 37 | ), 38 | ) 39 | await session.execute(stmt) 40 | await session.commit() 41 | 42 | 43 | async def add_score( 44 | session: AsyncSession, 45 | telegram_id: int, 46 | score: int, 47 | ): 48 | """ 49 | Добавление записи об игровой сессии пользователя 50 | :param session: сессия СУБД 51 | :param telegram_id: айди пользователя 52 | :param score: счёт пользователя 53 | """ 54 | new_game = Game( 55 | user_id=telegram_id, 56 | score=score, 57 | ) 58 | session.add(new_game) 59 | await session.commit() 60 | 61 | 62 | async def get_total_score_for_user( 63 | session: AsyncSession, 64 | telegram_id: int, 65 | ) -> int: 66 | """ 67 | Возвращает сумму очков для заданного игрока 68 | :param session: сессия СУБД 69 | :param telegram_id: айди пользователя в Telegram 70 | :return: сумма очков пользователя, число 71 | """ 72 | user = await session.get( 73 | User, {"telegram_id": telegram_id}, 74 | options=[selectinload(User.games)] 75 | ) 76 | return sum(item.score for item in user.games) 77 | 78 | 79 | async def get_last_games( 80 | session: AsyncSession, 81 | number_of_games: int, 82 | ) -> list[Game]: 83 | """ 84 | Получение N последних игр всех пользователей 85 | :param session: сессия СУБД 86 | :param number_of_games: количество игр 87 | :return: требуемое количество объектов Game (может быть меньше запрашиваемого) 88 | """ 89 | stmt = ( 90 | select(Game) 91 | .order_by(Game.created_at.desc()) 92 | .limit(number_of_games) 93 | .options(joinedload(Game.user)) 94 | ) 95 | result = await session.execute(stmt) 96 | games = result.scalars().all() 97 | games = cast(list[Game], games) 98 | return games 99 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | 8 | from alembic import context 9 | from bot.db import Base 10 | from bot.config_reader import parse_settings, Settings 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 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 = Base.metadata 26 | bot_config: Settings = parse_settings() 27 | 28 | config.set_main_option( 29 | 'sqlalchemy.url', 30 | str(bot_config.db_url) 31 | ) 32 | 33 | # other values from the config, defined by the needs of env.py, 34 | # can be acquired: 35 | # my_important_option = config.get_main_option("my_important_option") 36 | # ... etc. 37 | 38 | 39 | def run_migrations_offline() -> None: 40 | """Run migrations in 'offline' mode. 41 | 42 | This configures the context with just a URL 43 | and not an Engine, though an Engine is acceptable 44 | here as well. By skipping the Engine creation 45 | we don't even need a DBAPI to be available. 46 | 47 | Calls to context.execute() here emit the given string to the 48 | script output. 49 | 50 | """ 51 | url = config.get_main_option("sqlalchemy.url") 52 | context.configure( 53 | url=url, 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 | """In this scenario we need to create an Engine 72 | and associate a connection with the context. 73 | 74 | """ 75 | 76 | connectable = async_engine_from_config( 77 | config.get_section(config.config_ini_section, {}), 78 | prefix="sqlalchemy.", 79 | poolclass=pool.NullPool, 80 | ) 81 | 82 | async with connectable.connect() as connection: 83 | await connection.run_sync(do_run_migrations) 84 | 85 | await connectable.dispose() 86 | 87 | 88 | def run_migrations_online() -> None: 89 | """Run migrations in 'online' mode.""" 90 | 91 | asyncio.run(run_async_migrations()) 92 | 93 | 94 | if context.is_offline_mode(): 95 | run_migrations_offline() 96 | else: 97 | run_migrations_online() 98 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/first_migration/bot/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | from bot.config_reader import get_config, DbConfig 8 | from bot.db import Base 9 | 10 | from alembic import context 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 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 = Base.metadata 26 | 27 | db_config: DbConfig = get_config(DbConfig, "db") 28 | config.set_main_option( 29 | 'sqlalchemy.url', 30 | str(db_config.dsn), 31 | ) 32 | 33 | 34 | # other values from the config, defined by the needs of env.py, 35 | # can be acquired: 36 | # my_important_option = config.get_main_option("my_important_option") 37 | # ... etc. 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 | url = config.get_main_option("sqlalchemy.url") 53 | context.configure( 54 | url=url, 55 | target_metadata=target_metadata, 56 | literal_binds=True, 57 | dialect_opts={"paramstyle": "named"}, 58 | ) 59 | 60 | with context.begin_transaction(): 61 | context.run_migrations() 62 | 63 | 64 | def do_run_migrations(connection: Connection) -> None: 65 | context.configure(connection=connection, target_metadata=target_metadata) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | async def run_async_migrations() -> None: 72 | """In this scenario we need to create an Engine 73 | and associate a connection with the context. 74 | 75 | """ 76 | 77 | connectable = async_engine_from_config( 78 | config.get_section(config.config_ini_section, {}), 79 | prefix="sqlalchemy.", 80 | poolclass=pool.NullPool, 81 | ) 82 | 83 | async with connectable.connect() as connection: 84 | await connection.run_sync(do_run_migrations) 85 | 86 | await connectable.dispose() 87 | 88 | 89 | def run_migrations_online() -> None: 90 | """Run migrations in 'online' mode.""" 91 | 92 | asyncio.run(run_async_migrations()) 93 | 94 | 95 | if context.is_offline_mode(): 96 | run_migrations_offline() 97 | else: 98 | run_migrations_online() 99 | -------------------------------------------------------------------------------- /08_Databases/03-alembic/migration_with_extra_steps/bot/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from sqlalchemy import pool 5 | from sqlalchemy.engine import Connection 6 | from sqlalchemy.ext.asyncio import async_engine_from_config 7 | from bot.config_reader import get_config, DbConfig 8 | from bot.db import Base 9 | 10 | from alembic import context 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 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 = Base.metadata 26 | 27 | db_config: DbConfig = get_config(DbConfig, "db") 28 | config.set_main_option( 29 | 'sqlalchemy.url', 30 | str(db_config.dsn), 31 | ) 32 | 33 | 34 | # other values from the config, defined by the needs of env.py, 35 | # can be acquired: 36 | # my_important_option = config.get_main_option("my_important_option") 37 | # ... etc. 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 | url = config.get_main_option("sqlalchemy.url") 53 | context.configure( 54 | url=url, 55 | target_metadata=target_metadata, 56 | literal_binds=True, 57 | dialect_opts={"paramstyle": "named"}, 58 | ) 59 | 60 | with context.begin_transaction(): 61 | context.run_migrations() 62 | 63 | 64 | def do_run_migrations(connection: Connection) -> None: 65 | context.configure(connection=connection, target_metadata=target_metadata) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | async def run_async_migrations() -> None: 72 | """In this scenario we need to create an Engine 73 | and associate a connection with the context. 74 | 75 | """ 76 | 77 | connectable = async_engine_from_config( 78 | config.get_section(config.config_ini_section, {}), 79 | prefix="sqlalchemy.", 80 | poolclass=pool.NullPool, 81 | ) 82 | 83 | async with connectable.connect() as connection: 84 | await connection.run_sync(do_run_migrations) 85 | 86 | await connectable.dispose() 87 | 88 | 89 | def run_migrations_online() -> None: 90 | """Run migrations in 'online' mode.""" 91 | 92 | asyncio.run(run_async_migrations()) 93 | 94 | 95 | if context.is_offline_mode(): 96 | run_migrations_offline() 97 | else: 98 | run_migrations_online() 99 | -------------------------------------------------------------------------------- /10_Template/img-converter/app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from uuid import UUID 4 | 5 | from faststream import FastStream, Logger 6 | from faststream.nats import JStream, NatsBroker, NatsMessage, PullSub 7 | from nats.js.api import DeliverPolicy 8 | from nats.js.errors import ObjectNotFoundError 9 | from PIL import Image, ImageDraw, ImageFont 10 | from pydantic import BaseModel, Field 11 | 12 | import io 13 | 14 | broker = NatsBroker(os.getenv("NATS_URL")) # "nats://nats:4222" 15 | app = FastStream(broker) 16 | 17 | stream = JStream(name="KV_watermarker-tasks", declare=False) 18 | 19 | class Task(BaseModel): 20 | chat_id: int = Field() 21 | img_uuid: UUID = Field() 22 | img_format: str = Field() 23 | watermark: str = Field() 24 | 25 | @broker.subscriber( 26 | "$KV.watermarker-tasks.{img_uuid}", 27 | stream=stream, 28 | deliver_policy=DeliverPolicy("new"), 29 | durable="True", 30 | pull_sub=PullSub(batch_size=10), 31 | retry=True, 32 | filter=lambda msg: msg.headers.get("KV-Operation") is None, 33 | ) 34 | async def handler(task: Task, logger: Logger, msg: NatsMessage): 35 | logger.info(task.img_uuid) 36 | images = await broker.object_storage("watermarker-images", declare=False) 37 | done_tasks = await broker.key_value("watermarker-done-tasks", declare=False) 38 | buf = io.BytesIO() 39 | try: 40 | await images.get("in-" + str(task.img_uuid), buf) 41 | except ObjectNotFoundError: 42 | logger.info("Nack waiting for image to be uploaded") 43 | await msg.nack(1) 44 | return 45 | image = Image.open(io.BytesIO(buf.getbuffer())) 46 | width, height = image.size 47 | position = (width / 2, height / 2) 48 | img_fraction = 0.80 49 | breakpoint = width * img_fraction 50 | jumpsize = 50 51 | fontsize = 10 52 | font = ImageFont.truetype("font.otf", size=fontsize) 53 | 54 | while True: 55 | size = font.getbbox(task.watermark) 56 | width = size[2]-size[0] 57 | if width < breakpoint: 58 | fontsize += jumpsize 59 | else: 60 | jumpsize = jumpsize // 2 61 | fontsize -= jumpsize 62 | font = ImageFont.truetype("font.otf", size=fontsize) 63 | if jumpsize <= 2: 64 | break 65 | draw = ImageDraw.Draw(image) 66 | draw.text(position, task.watermark, font=font, fill=(240, 10, 10), anchor="mm", align="center") 67 | export_buf = io.BytesIO() 68 | image.save(export_buf, format=task.img_format.upper()) 69 | await images.put("out-" + str(task.img_uuid), export_buf.getvalue()) 70 | await done_tasks.create(str(task.img_uuid), msg.body) 71 | 72 | @broker.subscriber( 73 | "$KV.watermarker-tasks.{img_uuid}", 74 | stream=stream, 75 | deliver_policy=DeliverPolicy("new"), 76 | durable="True", 77 | pull_sub=PullSub(batch_size=10), 78 | retry=True, 79 | filter=lambda msg: msg.headers.get("KV-Operation") is not None, 80 | ) 81 | async def trash(data: bytes, msg: NatsMessage): 82 | await msg.ack() 83 | 84 | if __name__ == "__main__": 85 | asyncio.run(app.run()) 86 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/test_dice.py: -------------------------------------------------------------------------------- 1 | # Часть импортов нужна будет далее, 2 | # когда перейдём непосредственно к тестовой функции 3 | from datetime import datetime 4 | 5 | import pytest 6 | from aiogram.dispatcher.event.bases import UNHANDLED 7 | from aiogram.enums import ChatType, DiceEmoji 8 | from aiogram.methods import SendMessage, SendDice 9 | from aiogram.methods.base import TelegramType 10 | from aiogram.types import Update, Chat, User, Message, Dice 11 | 12 | 13 | def make_incoming_message(chat: Chat) -> Message: 14 | """ 15 | Генерирует текстовое сообщение с командой /dice от юзера к боту 16 | :param chat: объект чата, в котором происходит общение 17 | :return: объект Message с текстовой командой /dice 18 | """ 19 | return Message( 20 | message_id=1, 21 | chat=chat, 22 | from_user=User(id=chat.id, is_bot=False, first_name="User"), 23 | date=datetime.now(), 24 | text="/dice" 25 | ) 26 | 27 | 28 | def make_dice_outgoing_message(chat: Chat, is_win: bool) -> Message: 29 | """ 30 | Генерирует исходящее сообщение от бота, содержащее 31 | кубик (dice) с предопределённым значением 32 | :param chat: объект чата, в котором происходит общение 33 | :param is_win: True, если надо сгенерировать "выигрышный" кубик 34 | :return: объект Message с кубиком (dice) 35 | """ 36 | value = 1 if is_win else 2 37 | return Message( 38 | message_id=1, 39 | chat=chat, 40 | from_user=User(id=1, is_bot=True, first_name="Bot"), 41 | date=datetime.now(), 42 | dice=Dice(emoji=DiceEmoji.DICE, value=value) 43 | ) 44 | 45 | 46 | @pytest.mark.asyncio 47 | @pytest.mark.parametrize( 48 | "is_win, expected_message", 49 | [ 50 | [True, "Успех!"], 51 | [False, "В другой раз повезёт!"] 52 | ] 53 | ) 54 | async def test_dice(dp, bot, is_win: bool, expected_message: str): 55 | chat = Chat(id=123456, type=ChatType.PRIVATE) 56 | 57 | # Создание ответного сообщения от Telegram с нужным кубиком 58 | bot.add_result_for( 59 | method=SendDice, 60 | ok=True, 61 | result=make_dice_outgoing_message(chat=chat, is_win=is_win) 62 | ) 63 | 64 | # Создание подтверждение от Telegram в ответ на отправку ботом 65 | # текстовой реакции на результат 66 | bot.add_result_for( 67 | method=SendMessage, 68 | ok=True, 69 | # result, т.е. то, что ответит Telegram на этот вызов, сейчас не интересно 70 | ) 71 | 72 | result = await dp.feed_update( 73 | bot, 74 | Update(message=make_incoming_message(chat=chat), update_id=1) 75 | ) 76 | assert result is not UNHANDLED 77 | # Проверка, что первым сообщением, которое отправил бот, был дайс 78 | outgoing_dice_message: TelegramType = bot.get_request() 79 | assert isinstance(outgoing_dice_message, SendDice) 80 | 81 | # Проверка, что вторым сообщением, которое отправил бот, было текстовое 82 | # сообщение об успехе 83 | outgoing_text_message: TelegramType = bot.get_request() 84 | assert isinstance(outgoing_text_message, SendMessage) 85 | assert outgoing_text_message.text == expected_message 86 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/bot/handlers/ordering_food.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.filters import Command 3 | from aiogram.fsm.context import FSMContext 4 | from aiogram.types import Message, ReplyKeyboardRemove, ReplyKeyboardMarkup, KeyboardButton 5 | 6 | from bot.states import OrderFoodStates 7 | 8 | router = Router(name="Food Order Router") 9 | 10 | # Эти значения далее будут подставляться в итоговый текст, отсюда 11 | # такая на первый взгляд странная форма прилагательных 12 | available_food_names = ["Суши", "Спагетти", "Хачапури"] 13 | available_food_sizes = ["Маленькую", "Среднюю", "Большую"] 14 | 15 | 16 | def make_row_keyboard(items: list[str]) -> ReplyKeyboardMarkup: 17 | """ 18 | Создаёт реплай-клавиатуру с кнопками в один ряд 19 | :param items: список текстов для кнопок 20 | :return: объект реплай-клавиатуры 21 | """ 22 | row = [KeyboardButton(text=item) for item in items] 23 | return ReplyKeyboardMarkup(keyboard=[row], resize_keyboard=True) 24 | 25 | 26 | @router.message(Command("food")) 27 | async def cmd_food(message: Message, state: FSMContext): 28 | await message.answer( 29 | text="Выберите блюдо:", 30 | reply_markup=make_row_keyboard(available_food_names) 31 | ) 32 | # Установка пользователю состояния "выбирает название" 33 | await state.set_state(OrderFoodStates.choosing_food_name) 34 | 35 | # Этап выбора блюда # 36 | 37 | 38 | @router.message(OrderFoodStates.choosing_food_name, F.text.in_(available_food_names)) 39 | async def food_chosen(message: Message, state: FSMContext): 40 | await state.update_data(chosen_food=message.text.lower()) 41 | await message.answer( 42 | text="Спасибо. Теперь, пожалуйста, выберите размер порции:", 43 | reply_markup=make_row_keyboard(available_food_sizes) 44 | ) 45 | await state.set_state(OrderFoodStates.choosing_food_size) 46 | 47 | 48 | @router.message(OrderFoodStates.choosing_food_name) 49 | async def food_chosen_incorrectly(message: Message): 50 | await message.answer( 51 | text="Я не знаю такого блюда.\n\n" 52 | "Пожалуйста, выберите одно из названий из списка ниже:", 53 | reply_markup=make_row_keyboard(available_food_names) 54 | ) 55 | 56 | # Этап выбора размера порции и отображение сводной информации # 57 | 58 | 59 | @router.message(OrderFoodStates.choosing_food_size, F.text.in_(available_food_sizes)) 60 | async def food_size_chosen(message: Message, state: FSMContext): 61 | user_data = await state.get_data() 62 | user_data["chosen_size"] = message.text.lower() 63 | await message.answer( 64 | text=f"Вы выбрали {message.text.lower()} порцию {user_data['chosen_food']}.\n" 65 | f"Спасибо за заказ! Чтобы сделать ещё один, снова нажмите на /food.", 66 | reply_markup=ReplyKeyboardRemove() 67 | ) 68 | # Сброс состояния и сохранённых данных у пользователя 69 | await state.clear() 70 | 71 | 72 | @router.message(OrderFoodStates.choosing_food_size) 73 | async def food_size_chosen_incorrectly(message: Message): 74 | await message.answer( 75 | text="Я не знаю такого размера порции.\n\n" 76 | "Пожалуйста, выберите один из вариантов из списка ниже:", 77 | reply_markup=make_row_keyboard(available_food_sizes) 78 | ) 79 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | import pytest 5 | import pytest_asyncio 6 | from aiogram import Dispatcher 7 | from aiogram.fsm.storage.memory import MemoryStorage 8 | from alembic.command import upgrade, downgrade 9 | from alembic.config import Config as AlembicConfig 10 | from bot.config_reader import parse_settings, Settings 11 | from bot.handlers import get_routers 12 | from bot.middlewares import DbSessionMiddleware 13 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker 14 | from tests.mocked_aiogram import MockedBot, MockedSession 15 | 16 | 17 | # Фикстура для получения экземпляра фейкового бота 18 | @pytest.fixture(scope="session") 19 | def bot() -> MockedBot: 20 | bot = MockedBot() 21 | bot.session = MockedSession() 22 | return bot 23 | 24 | 25 | # Фикстура, которая получает объект настроек 26 | @pytest.fixture(scope="session") 27 | def settings() -> Settings: 28 | return parse_settings() 29 | 30 | 31 | # Фикстура, которая создаёт объект конфигурации alembic для применения миграций 32 | @pytest.fixture(scope="session") 33 | def alembic_config(settings: Settings) -> AlembicConfig: 34 | project_dir = Path(__file__).parent.parent 35 | alembic_ini_path = Path.joinpath(project_dir.absolute(), "alembic.ini").as_posix() 36 | alembic_cfg = AlembicConfig(alembic_ini_path) 37 | 38 | migrations_dir_path = Path.joinpath(project_dir.absolute(), "bot", "db", "migrations").as_posix() 39 | alembic_cfg.set_main_option("script_location", migrations_dir_path) 40 | alembic_cfg.set_main_option("sqlalchemy.url", str(settings.db_url)) 41 | return alembic_cfg 42 | 43 | 44 | # Фикстура для получения асинхронного "движка" для работы с СУБД 45 | @pytest.fixture(scope="session") 46 | def engine(settings): 47 | engine = create_async_engine(str(settings.db_url)) 48 | yield engine 49 | engine.sync_engine.dispose() 50 | 51 | 52 | # Обновлённая фикстура для получения экземпляра диспетчера aiogram 53 | # Здесь же надо ещё раз подключить все нужные мидлвари 54 | @pytest.fixture(scope="session") 55 | def dp(engine) -> Dispatcher: 56 | dispatcher = Dispatcher(storage=MemoryStorage()) 57 | sessionmaker = async_sessionmaker(engine, expire_on_commit=False) 58 | dispatcher.update.middleware(DbSessionMiddleware(session_pool=sessionmaker)) 59 | dispatcher.include_routers(*get_routers()) 60 | return dispatcher 61 | 62 | 63 | # Фикстура, которая в каждом модуле применяет миграции 64 | # А после завершения тестов в модуле откатывает базу к нулевому состоянию (без данных) 65 | @pytest_asyncio.fixture(scope="module") 66 | def create(engine, alembic_config: AlembicConfig): 67 | upgrade(alembic_config, "head") 68 | yield engine 69 | downgrade(alembic_config, "base") 70 | 71 | 72 | # Фикстура, которая передаёт в тест сессию из "движка" 73 | @pytest_asyncio.fixture(scope="function") 74 | async def session(engine, create): 75 | async with AsyncSession(engine) as s: 76 | yield s 77 | 78 | 79 | # Если pytest ругается на event loop, раскомментируйте эту фикстуру 80 | # @pytest.fixture(scope="session") 81 | # def event_loop(): 82 | # try: 83 | # return asyncio.get_running_loop() 84 | # except RuntimeError: 85 | # loop = asyncio.new_event_loop() 86 | # asyncio.set_event_loop(loop) 87 | # return loop 88 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/bot/handlers/ordering_food.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.filters import Command 3 | from aiogram.fsm.context import FSMContext 4 | from aiogram.types import Message, ReplyKeyboardRemove, ReplyKeyboardMarkup, KeyboardButton 5 | 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | 8 | from bot.db.requests import create_order 9 | from bot.states import OrderFoodStates 10 | 11 | router = Router(name="Food Order Router") 12 | 13 | # Эти значения далее будут подставляться в итоговый текст, отсюда 14 | # такая на первый взгляд странная форма прилагательных 15 | available_food_names = ["Суши", "Спагетти", "Хачапури"] 16 | available_food_sizes = ["Маленькую", "Среднюю", "Большую"] 17 | 18 | 19 | def make_row_keyboard(items: list[str]) -> ReplyKeyboardMarkup: 20 | """ 21 | Создаёт реплай-клавиатуру с кнопками в один ряд 22 | :param items: список текстов для кнопок 23 | :return: объект реплай-клавиатуры 24 | """ 25 | row = [KeyboardButton(text=item) for item in items] 26 | return ReplyKeyboardMarkup(keyboard=[row], resize_keyboard=True) 27 | 28 | 29 | @router.message(Command("food")) 30 | async def cmd_food(message: Message, state: FSMContext): 31 | await message.answer( 32 | text="Выберите блюдо:", 33 | reply_markup=make_row_keyboard(available_food_names) 34 | ) 35 | # Устанавливаем пользователю состояние "выбирает название" 36 | await state.set_state(OrderFoodStates.choosing_food_name) 37 | 38 | # Этап выбора блюда # 39 | 40 | 41 | @router.message(OrderFoodStates.choosing_food_name, F.text.in_(available_food_names)) 42 | async def food_chosen(message: Message, state: FSMContext): 43 | await state.update_data(chosen_food=message.text.lower()) 44 | await message.answer( 45 | text="Спасибо. Теперь, пожалуйста, выберите размер порции:", 46 | reply_markup=make_row_keyboard(available_food_sizes) 47 | ) 48 | await state.set_state(OrderFoodStates.choosing_food_size) 49 | 50 | 51 | @router.message(OrderFoodStates.choosing_food_name) 52 | async def food_chosen_incorrectly(message: Message): 53 | await message.answer( 54 | text="Я не знаю такого блюда.\n\n" 55 | "Пожалуйста, выберите одно из названий из списка ниже:", 56 | reply_markup=make_row_keyboard(available_food_names) 57 | ) 58 | 59 | # Этап выбора размера порции и отображение сводной информации # 60 | 61 | 62 | @router.message(OrderFoodStates.choosing_food_size, F.text.in_(available_food_sizes)) 63 | async def food_size_chosen(message: Message, state: FSMContext, session: AsyncSession): 64 | user_data = await state.get_data() 65 | user_data["chosen_size"] = message.text.lower() 66 | await create_order(session, user_data, message.from_user.id) 67 | await message.answer( 68 | text=f"Вы выбрали {message.text.lower()} порцию {user_data['chosen_food']}.\n" 69 | f"Спасибо за заказ! Чтобы сделать ещё один, снова нажмите на /food.", 70 | reply_markup=ReplyKeyboardRemove() 71 | ) 72 | # Сброс состояния и сохранённых данных у пользователя 73 | await state.clear() 74 | 75 | 76 | @router.message(OrderFoodStates.choosing_food_size) 77 | async def food_size_chosen_incorrectly(message: Message): 78 | await message.answer( 79 | text="Я не знаю такого размера порции.\n\n" 80 | "Пожалуйста, выберите один из вариантов из списка ниже:", 81 | reply_markup=make_row_keyboard(available_food_sizes) 82 | ) 83 | -------------------------------------------------------------------------------- /10_Template/database/migration/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from dynaconf import Dynaconf 6 | 7 | # this is the Alembic BotConfig object, which provides 8 | # access to the values within the .ini file in use. 9 | from sqlalchemy.ext.asyncio import create_async_engine 10 | 11 | from config import Config 12 | from database.models import Base 13 | 14 | config = context.config 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 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 = 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 | # ... etc. 32 | 33 | 34 | def include_name(name, type_, _): 35 | if type_ == 'table': 36 | return name in target_metadata.tables 37 | return True 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 | settings = Dynaconf( 53 | envvar_prefix='APP_CONF', 54 | settings_files=['settings.toml', '.secrets.toml'], 55 | ) 56 | app_config: Config = Config.parse_obj(settings) 57 | # url = config.get_main_option("sqlalchemy.url") 58 | context.configure( 59 | url=app_config.db.uri, 60 | target_metadata=target_metadata, 61 | literal_binds=True, 62 | dialect_opts={'paramstyle': 'named'}, 63 | include_name=include_name, 64 | ) 65 | 66 | with context.begin_transaction(): 67 | context.run_migrations() 68 | 69 | 70 | def do_migrations(connection): 71 | context.configure( 72 | connection=connection, 73 | target_metadata=target_metadata, 74 | include_name=include_name, 75 | ) 76 | with context.begin_transaction(): 77 | context.run_migrations() 78 | 79 | 80 | async def run_migrations_online() -> None: 81 | """Run migrations in 'online' mode. 82 | 83 | In this scenario we need to create an Engine 84 | and associate a connection with the context. 85 | 86 | """ 87 | settings = Dynaconf( 88 | envvar_prefix='APP_CONF', 89 | settings_files=[ 90 | 'ROOT DIR OF PROJECT/settings.toml', 91 | 'ROOT DIR OF PROJECT/.secrets.toml', 92 | ], 93 | ) 94 | app_config: Config = Config.parse_obj(settings) 95 | connectable = create_async_engine( 96 | app_config.db.uri, **app_config.db.orm.engine.dict() 97 | ) 98 | 99 | async with connectable.connect() as connection: 100 | await connection.run_sync(do_migrations) 101 | 102 | await connectable.dispose() 103 | 104 | 105 | if context.is_offline_mode(): 106 | run_migrations_offline() 107 | else: 108 | asyncio.run(run_migrations_online()) 109 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/test_user_id_handlers.py: -------------------------------------------------------------------------------- 1 | # Часть импортов нужна будет далее, 2 | # когда перейдём непосредственно к тестовой функции 3 | from datetime import datetime 4 | 5 | import pytest 6 | from aiogram.dispatcher.event.bases import UNHANDLED 7 | from aiogram.enums import ChatType 8 | from aiogram.methods import SendMessage, AnswerCallbackQuery 9 | from aiogram.methods.base import TelegramType 10 | from aiogram.types import ( 11 | Update, Chat, User, Message, CallbackQuery, 12 | InlineKeyboardMarkup, InlineKeyboardButton 13 | ) 14 | 15 | # Константы для этого набора тестов 16 | user_id = 123456 17 | callback_data = "myid" 18 | 19 | 20 | def make_incoming_message() -> Message: 21 | """ 22 | Генерирует текстовое сообщение с командой /id от юзера к боту 23 | :return: объект Message с текстовой командой /id 24 | """ 25 | return Message( 26 | message_id=1, 27 | chat=Chat(id=user_id, type=ChatType.PRIVATE), 28 | from_user=User(id=user_id, is_bot=False, first_name="User"), 29 | date=datetime.now(), 30 | text="/id" 31 | ) 32 | 33 | 34 | def make_incoming_callback() -> CallbackQuery: 35 | """ 36 | Генерирует объект CallbackQuery, 37 | имитирующий результат нажатия юзером кнопки 38 | с callback_data "myid" 39 | :return: объект CallbackQuery 40 | """ 41 | return CallbackQuery( 42 | id="1111111111111", 43 | chat_instance="22222222222222", 44 | from_user=User(id=user_id, is_bot=False, first_name="User"), 45 | data=callback_data, 46 | # message необязателен в этом тесте, можно пропустить 47 | ) 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_id_command(dp, bot): 52 | # Создание ответного сообщения от Telegram в ответ на команду /id 53 | bot.add_result_for( 54 | method=SendMessage, 55 | ok=True, 56 | # result сейчас не нужен 57 | ) 58 | 59 | # "Отправка" сообщения с командой /id 60 | update = await dp.feed_update( 61 | bot, 62 | Update(message=make_incoming_message(), update_id=1) 63 | ) 64 | 65 | # Проверка, что сообщение обработано 66 | assert update is not UNHANDLED 67 | 68 | # Получение отправленного ботом сообщения 69 | outgoing_message: TelegramType = bot.get_request() 70 | # Проверка содержамого: тип, текст, наличие клавиатуры, что внутри клавиатуры 71 | assert isinstance(outgoing_message, SendMessage) 72 | assert outgoing_message.text == "Нажмите на кнопку ниже:" 73 | assert outgoing_message.reply_markup is not None 74 | markup = outgoing_message.reply_markup 75 | assert isinstance(markup, InlineKeyboardMarkup) 76 | button: InlineKeyboardButton = markup.inline_keyboard[0][0] 77 | assert button.text == "Узнать свой ID" 78 | assert button.callback_data == "myid" 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_myid_callback(dp, bot): 83 | # Создание ответного сообщения от Telegram при ответе на колбэк 84 | bot.add_result_for( 85 | method=AnswerCallbackQuery, 86 | ok=True 87 | ) 88 | 89 | # Отправка коллбэка с data = myid 90 | update = await dp.feed_update( 91 | bot, 92 | Update(callback_query=make_incoming_callback(), update_id=1) 93 | ) 94 | 95 | # Проверка, что коллбэк обработан 96 | assert update is not UNHANDLED 97 | 98 | # Получение отправленного ботом коллбэка 99 | outgoing_callback: TelegramType = bot.get_request() 100 | 101 | # Проверка содержимого: тип, текст, вид алерта 102 | assert isinstance(outgoing_callback, AnswerCallbackQuery) 103 | assert outgoing_callback.text == f"Ваш айди: {user_id}" 104 | assert outgoing_callback.show_alert in (None, False) 105 | -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/mocked_aiogram.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is taken from 3 | https://github.com/aiogram/aiogram/blob/dev-3.x/tests/mocked_bot.py 4 | (commit 74e00a30b12a2adb47237079bb554f15755ec604) 5 | with slight modifications 6 | """ 7 | 8 | from collections import deque 9 | from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type 10 | 11 | from aiogram import Bot 12 | from aiogram.client.session.base import BaseSession 13 | from aiogram.methods import TelegramMethod 14 | from aiogram.methods.base import Response, TelegramType 15 | from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User 16 | 17 | 18 | class MockedSession(BaseSession): 19 | def __init__(self): 20 | super(MockedSession, self).__init__() 21 | self.responses: Deque[Response[TelegramType]] = deque() 22 | self.requests: Deque[TelegramMethod[TelegramType]] = deque() 23 | self.closed = True 24 | 25 | def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: 26 | self.responses.append(response) 27 | return response 28 | 29 | def get_request(self) -> TelegramMethod[TelegramType]: 30 | return self.requests.popleft() 31 | 32 | async def close(self): 33 | self.closed = True 34 | 35 | async def make_request( 36 | self, 37 | bot: Bot, 38 | method: TelegramMethod[TelegramType], 39 | timeout: Optional[int] = UNSET_PARSE_MODE, 40 | ) -> TelegramType: 41 | self.closed = False 42 | self.requests.append(method) 43 | response: Response[TelegramType] = self.responses.popleft() 44 | self.check_response( 45 | bot=bot, 46 | method=method, 47 | status_code=response.error_code, 48 | content=response.model_dump_json(), 49 | ) 50 | return response.result # type: ignore 51 | 52 | async def stream_content( 53 | self, 54 | url: str, 55 | headers: Optional[Dict[str, Any]] = None, 56 | timeout: int = 30, 57 | chunk_size: int = 65536, 58 | raise_for_status: bool = True, 59 | ) -> AsyncGenerator[bytes, None]: # pragma: no cover 60 | yield b"" 61 | 62 | 63 | class MockedBot(Bot): 64 | if TYPE_CHECKING: 65 | session: MockedSession 66 | 67 | def __init__(self, **kwargs): 68 | super(MockedBot, self).__init__( 69 | kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs 70 | ) 71 | self._me = User( 72 | id=self.id, 73 | is_bot=True, 74 | first_name="BotName", 75 | last_name="BotSurname", 76 | username="bot", 77 | language_code="en-US", 78 | ) 79 | 80 | def add_result_for( 81 | self, 82 | method: Type[TelegramMethod[TelegramType]], 83 | ok: bool, 84 | result: TelegramType = None, 85 | description: Optional[str] = None, 86 | error_code: int = 200, 87 | migrate_to_chat_id: Optional[int] = None, 88 | retry_after: Optional[int] = None, 89 | ) -> Response[TelegramType]: 90 | response = Response[method.__returning__]( # type: ignore 91 | ok=ok, 92 | result=result, 93 | description=description, 94 | error_code=error_code, 95 | parameters=ResponseParameters( 96 | migrate_to_chat_id=migrate_to_chat_id, 97 | retry_after=retry_after, 98 | ), 99 | ) 100 | self.session.add_result(response) 101 | return response 102 | 103 | def get_request(self) -> TelegramMethod[TelegramType]: 104 | return self.session.get_request() 105 | -------------------------------------------------------------------------------- /04_Testing/04_Testing_DB/tests/mocked_aiogram.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is taken from 3 | https://github.com/aiogram/aiogram/blob/dev-3.x/tests/mocked_bot.py 4 | (commit 74e00a30b12a2adb47237079bb554f15755ec604) 5 | with slight modifications 6 | """ 7 | 8 | from collections import deque 9 | from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type 10 | 11 | from aiogram import Bot 12 | from aiogram.client.session.base import BaseSession 13 | from aiogram.methods import TelegramMethod 14 | from aiogram.methods.base import Response, TelegramType 15 | from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User 16 | 17 | 18 | class MockedSession(BaseSession): 19 | def __init__(self): 20 | super(MockedSession, self).__init__() 21 | self.responses: Deque[Response[TelegramType]] = deque() 22 | self.requests: Deque[TelegramMethod[TelegramType]] = deque() 23 | self.closed = True 24 | 25 | def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: 26 | self.responses.append(response) 27 | return response 28 | 29 | def get_request(self) -> TelegramMethod[TelegramType]: 30 | return self.requests.popleft() 31 | 32 | async def close(self): 33 | self.closed = True 34 | 35 | async def make_request( 36 | self, 37 | bot: Bot, 38 | method: TelegramMethod[TelegramType], 39 | timeout: Optional[int] = UNSET_PARSE_MODE, 40 | ) -> TelegramType: 41 | self.closed = False 42 | self.requests.append(method) 43 | response: Response[TelegramType] = self.responses.popleft() 44 | self.check_response( 45 | bot=bot, 46 | method=method, 47 | status_code=response.error_code, 48 | content=response.model_dump_json(), 49 | ) 50 | return response.result # type: ignore 51 | 52 | async def stream_content( 53 | self, 54 | url: str, 55 | headers: Optional[Dict[str, Any]] = None, 56 | timeout: int = 30, 57 | chunk_size: int = 65536, 58 | raise_for_status: bool = True, 59 | ) -> AsyncGenerator[bytes, None]: # pragma: no cover 60 | yield b"" 61 | 62 | 63 | class MockedBot(Bot): 64 | if TYPE_CHECKING: 65 | session: MockedSession 66 | 67 | def __init__(self, **kwargs): 68 | super(MockedBot, self).__init__( 69 | kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs 70 | ) 71 | self._me = User( 72 | id=self.id, 73 | is_bot=True, 74 | first_name="BotName", 75 | last_name="BotSurname", 76 | username="bot", 77 | language_code="en-US", 78 | ) 79 | 80 | def add_result_for( 81 | self, 82 | method: Type[TelegramMethod[TelegramType]], 83 | ok: bool, 84 | result: TelegramType = None, 85 | description: Optional[str] = None, 86 | error_code: int = 200, 87 | migrate_to_chat_id: Optional[int] = None, 88 | retry_after: Optional[int] = None, 89 | ) -> Response[TelegramType]: 90 | response = Response[method.__returning__]( # type: ignore 91 | ok=ok, 92 | result=result, 93 | description=description, 94 | error_code=error_code, 95 | parameters=ResponseParameters( 96 | migrate_to_chat_id=migrate_to_chat_id, 97 | retry_after=retry_after, 98 | ), 99 | ) 100 | self.session.add_result(response) 101 | return response 102 | 103 | def get_request(self) -> TelegramMethod[TelegramType]: 104 | return self.session.get_request() 105 | -------------------------------------------------------------------------------- /04_Testing/02_Testing_handlers/tests/mocked_aiogram.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is taken from 3 | https://github.com/aiogram/aiogram/blob/dev-3.x/tests/mocked_bot.py 4 | (commit 74e00a30b12a2adb47237079bb554f15755ec604) 5 | with slight modifications 6 | """ 7 | 8 | from collections import deque 9 | from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type 10 | 11 | from aiogram import Bot 12 | from aiogram.client.session.base import BaseSession 13 | from aiogram.methods import TelegramMethod 14 | from aiogram.methods.base import Response, TelegramType 15 | from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User 16 | 17 | 18 | class MockedSession(BaseSession): 19 | def __init__(self): 20 | super(MockedSession, self).__init__() 21 | self.responses: Deque[Response[TelegramType]] = deque() 22 | self.requests: Deque[TelegramMethod[TelegramType]] = deque() 23 | self.closed = True 24 | 25 | def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: 26 | self.responses.append(response) 27 | return response 28 | 29 | def get_request(self) -> TelegramMethod[TelegramType]: 30 | return self.requests.popleft() 31 | 32 | async def close(self): 33 | self.closed = True 34 | 35 | async def make_request( 36 | self, 37 | bot: Bot, 38 | method: TelegramMethod[TelegramType], 39 | timeout: Optional[int] = UNSET_PARSE_MODE, 40 | ) -> TelegramType: 41 | self.closed = False 42 | self.requests.append(method) 43 | response: Response[TelegramType] = self.responses.popleft() 44 | self.check_response( 45 | bot=bot, 46 | method=method, 47 | status_code=response.error_code, 48 | content=response.model_dump_json(), 49 | ) 50 | return response.result # type: ignore 51 | 52 | async def stream_content( 53 | self, 54 | url: str, 55 | headers: Optional[Dict[str, Any]] = None, 56 | timeout: int = 30, 57 | chunk_size: int = 65536, 58 | raise_for_status: bool = True, 59 | ) -> AsyncGenerator[bytes, None]: # pragma: no cover 60 | yield b"" 61 | 62 | 63 | class MockedBot(Bot): 64 | if TYPE_CHECKING: 65 | session: MockedSession 66 | 67 | def __init__(self, **kwargs): 68 | super(MockedBot, self).__init__( 69 | kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs 70 | ) 71 | self._me = User( 72 | id=self.id, 73 | is_bot=True, 74 | first_name="BotName", 75 | last_name="BotSurname", 76 | username="bot", 77 | language_code="en-US", 78 | ) 79 | 80 | def add_result_for( 81 | self, 82 | method: Type[TelegramMethod[TelegramType]], 83 | ok: bool, 84 | result: TelegramType = None, 85 | description: Optional[str] = None, 86 | error_code: int = 200, 87 | migrate_to_chat_id: Optional[int] = None, 88 | retry_after: Optional[int] = None, 89 | ) -> Response[TelegramType]: 90 | response = Response[method.__returning__]( # type: ignore 91 | ok=ok, 92 | result=result, 93 | description=description, 94 | error_code=error_code, 95 | parameters=ResponseParameters( 96 | migrate_to_chat_id=migrate_to_chat_id, 97 | retry_after=retry_after, 98 | ), 99 | ) 100 | self.session.add_result(response) 101 | return response 102 | 103 | def get_request(self) -> TelegramMethod[TelegramType]: 104 | return self.session.get_request() 105 | -------------------------------------------------------------------------------- /10_Template/bot/tests/mocked_aiogram.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is taken from 3 | https://github.com/aiogram/aiogram/blob/dev-3.x/tests/mocked_bot.py 4 | (commit 74e00a30b12a2adb47237079bb554f15755ec604) 5 | with slight modifications 6 | """ 7 | 8 | from collections import deque 9 | from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type 10 | 11 | from aiogram import Bot 12 | from aiogram.client.session.base import BaseSession 13 | from aiogram.methods import TelegramMethod 14 | from aiogram.methods.base import Response, TelegramType 15 | from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User 16 | 17 | 18 | class MockedSession(BaseSession): 19 | def __init__(self): 20 | super(MockedSession, self).__init__() 21 | self.responses: Deque[Response[TelegramType]] = deque() 22 | self.requests: Deque[TelegramMethod[TelegramType]] = deque() 23 | self.closed = True 24 | 25 | def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: 26 | self.responses.append(response) 27 | return response 28 | 29 | def get_request(self) -> TelegramMethod[TelegramType]: 30 | return self.requests.popleft() 31 | 32 | async def close(self): 33 | self.closed = True 34 | 35 | async def make_request( 36 | self, 37 | bot: Bot, 38 | method: TelegramMethod[TelegramType], 39 | timeout: Optional[int] = UNSET_PARSE_MODE, 40 | ) -> TelegramType: 41 | self.closed = False 42 | self.requests.append(method) 43 | response: Response[TelegramType] = self.responses.popleft() 44 | self.check_response( 45 | bot=bot, 46 | method=method, 47 | status_code=response.error_code, 48 | content=response.model_dump_json(), 49 | ) 50 | return response.result # type: ignore 51 | 52 | async def stream_content( 53 | self, 54 | url: str, 55 | headers: Optional[Dict[str, Any]] = None, 56 | timeout: int = 30, 57 | chunk_size: int = 65536, 58 | raise_for_status: bool = True, 59 | ) -> AsyncGenerator[bytes, None]: # pragma: no cover 60 | yield b"" 61 | 62 | 63 | class MockedBot(Bot): 64 | if TYPE_CHECKING: 65 | session: MockedSession 66 | 67 | def __init__(self, **kwargs): 68 | super(MockedBot, self).__init__( 69 | kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs 70 | ) 71 | self._me = User( 72 | id=self.id, 73 | is_bot=True, 74 | first_name="BotName", 75 | last_name="BotSurname", 76 | username="bot", 77 | language_code="en-US", 78 | ) 79 | 80 | def add_result_for( 81 | self, 82 | method: Type[TelegramMethod[TelegramType]], 83 | ok: bool, 84 | result: TelegramType = None, 85 | description: Optional[str] = None, 86 | error_code: int = 200, 87 | migrate_to_chat_id: Optional[int] = None, 88 | retry_after: Optional[int] = None, 89 | ) -> Response[TelegramType]: 90 | response = Response[method.__returning__]( # type: ignore 91 | ok=ok, 92 | result=result, 93 | description=description, 94 | error_code=error_code, 95 | parameters=ResponseParameters( 96 | migrate_to_chat_id=migrate_to_chat_id, 97 | retry_after=retry_after, 98 | ), 99 | ) 100 | self.session.add_result(response) 101 | return response 102 | 103 | def get_request(self) -> TelegramMethod[TelegramType]: 104 | return self.session.get_request() -------------------------------------------------------------------------------- /04_Testing/03_Testing_FSM/tests/test_ordering_food.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from aiogram import Dispatcher 5 | from aiogram.enums import ChatType 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.methods import SendMessage 8 | from aiogram.methods.base import TelegramType 9 | from aiogram.types import Message, Update, User, Chat 10 | 11 | from bot.states import OrderFoodStates 12 | from tests.mocked_aiogram import MockedBot 13 | 14 | user_id = 123456 15 | 16 | 17 | def make_message(text: str) -> Message: 18 | user = User(id=user_id, first_name="User", is_bot=False) 19 | chat = Chat(id=user_id, type=ChatType.PRIVATE) 20 | return Message(message_id=1, from_user=user, chat=chat, date=datetime.now(), text=text) 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_states_flow_orders(dp: Dispatcher, bot: MockedBot): 25 | 26 | # Получение контекста FSM для текущего юзера 27 | fsm_context: FSMContext = dp.fsm.get_context(bot=bot, user_id=user_id, chat_id=user_id) 28 | await fsm_context.set_state(None) 29 | 30 | # Альтернативный вариант 31 | # fsm_storage_key = StorageKey(bot_id=bot.id, user_id=user_id, chat_id=user_id) 32 | # # Очистка стейта 33 | # await dp.storage.set_state(fsm_storage_key, None) 34 | 35 | starting_messages = [ 36 | make_message("/start"), 37 | make_message("/food") 38 | ] 39 | 40 | for message in starting_messages: 41 | bot.add_result_for(SendMessage, ok=True) 42 | await dp.feed_update(bot, Update(message=message, update_id=1)) 43 | # Здесь и далее таким вызовом забирается 44 | # очередное сообщение бота из списка отправленных 45 | # Но пока что его содержимое не интересует 46 | bot.get_request() 47 | 48 | # Проверка стейта "выбор блюда" 49 | current_state = await fsm_context.get_state() 50 | # Альтернативный вариант 51 | # current_state = await dp.storage.get_state(fsm_storage_key) 52 | assert current_state == OrderFoodStates.choosing_food_name 53 | 54 | # Отправка некорректного значения названия блюда 55 | bot.add_result_for(SendMessage, ok=True) 56 | await dp.feed_update(bot, Update(message=make_message("ХЗ ЧТО"), update_id=1)) 57 | bot.get_request() 58 | 59 | # Проверка, что стейт не изменился 60 | current_state = await fsm_context.get_state() 61 | assert current_state == OrderFoodStates.choosing_food_name 62 | 63 | # Отправка корректного значения названия блюда 64 | bot.add_result_for(SendMessage, ok=True) 65 | await dp.feed_update(bot, Update(message=make_message("Суши"), update_id=1)) 66 | bot.get_request() 67 | 68 | # Проверка, что стейт изменился на "выбор размера" 69 | current_state = await fsm_context.get_state() 70 | assert current_state == OrderFoodStates.choosing_food_size 71 | 72 | # Отправка некорректного значения размера блюда 73 | bot.add_result_for(SendMessage, ok=True) 74 | await dp.feed_update(bot, Update(message=make_message("ХЗ ЧТО"), update_id=1)) 75 | bot.get_request() 76 | 77 | # Проверка, что стейт не изменился 78 | current_state = await fsm_context.get_state() 79 | assert current_state == OrderFoodStates.choosing_food_size 80 | 81 | # Отправка корректного значения размера блюда 82 | bot.add_result_for(SendMessage, ok=True) 83 | await dp.feed_update(bot, Update(message=make_message("Большую"), update_id=1)) 84 | 85 | # Получение отправленного ботом сообщения 86 | outgoing_message: TelegramType = bot.get_request() 87 | # Проверка, что бот написал "заказ" правильно 88 | assert isinstance(outgoing_message, SendMessage) 89 | assert outgoing_message.text == "Вы выбрали большую порцию суши.\nСпасибо за заказ! Чтобы сделать ещё один, снова нажмите на /food." 90 | 91 | # Проверка, что стейт сбросился 92 | current_state = await fsm_context.get_state() 93 | assert current_state is None 94 | -------------------------------------------------------------------------------- /10_Template/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = database/migration 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 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to database/migration/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:database/migration/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | 64 | 65 | [post_write_hooks] 66 | # post_write_hooks defines scripts or Python functions that are run 67 | # on newly generated revision scripts. See the documentation for further 68 | # detail and examples 69 | 70 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 71 | # hooks = black 72 | # black.type = console_scripts 73 | # black.entrypoint = black 74 | # black.options = -l 79 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 | --------------------------------------------------------------------------------