├── .dockerignore ├── .env.dist ├── .gitignore ├── Dockerfile ├── README.MD ├── alembic.ini ├── bot.py ├── docker-compose.yml ├── infrastructure ├── __init__.py ├── api │ ├── Dockerfile │ ├── __init__.py │ ├── app.py │ └── requirements.txt ├── database │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ └── users.py │ ├── repo │ │ ├── base.py │ │ ├── requests.py │ │ └── users.py │ └── setup.py ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── 343bb188ff78_create_users_table.py └── some_api │ ├── __init__.py │ ├── api.py │ └── base.py ├── nginx └── nginx.conf ├── requirements.txt ├── scripts ├── alembic │ ├── create_alembic.sh │ ├── create_migrations.sh │ └── run_migrations.sh └── postgres │ └── create_dump.sh └── tgbot ├── __init__.py ├── config.py ├── filters ├── __init__.py └── admin.py ├── handlers ├── __init__.py ├── admin.py ├── echo.py ├── simple_menu.py └── user.py ├── keyboards ├── __init__.py ├── inline.py └── reply.py ├── middlewares ├── __init__.py ├── config.py └── database.py ├── misc ├── __init__.py └── states.py └── services ├── __init__.py └── broadcaster.py /.dockerignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea/ 3 | cache/ 4 | README.MD 5 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | BOT_TOKEN=123456:Your-TokEn_ExaMple 2 | ADMINS=123456,654321 3 | USE_REDIS=False 4 | 5 | #POSTGRES_USER=someusername 6 | #POSTGRES_PASSWORD=postgres_pass12345 7 | #POSTGRES_DB=bot 8 | #DB_HOST=pg_database 9 | 10 | # REDIS_HOST=redis_cache 11 | # REDIS_PORT=6388 12 | # REDIS_DB=1 13 | # REDIS_PASSWORD=someredispass 14 | 15 | 16 | #WEBHOOK_EXPOSE=8001 17 | #WEBHOOK_APP_NAME=webhook 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Translations 35 | *.mo 36 | *.pot 37 | 38 | 39 | # pyenv 40 | .python-version 41 | 42 | # pipenv 43 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 44 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 45 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 46 | # install all needed dependencies. 47 | #Pipfile.lock 48 | 49 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 50 | __pypackages__/ 51 | 52 | # Environments 53 | .venv 54 | env/ 55 | venv/ 56 | ENV/ 57 | env.bak/ 58 | venv.bak/ 59 | 60 | # Pyre type checker 61 | .pyre/ 62 | .idea/ 63 | .env 64 | cache/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /usr/src/app/bot 4 | 5 | COPY requirements.txt /usr/src/app/bot 6 | 7 | RUN pip install -r /usr/src/app/bot/requirements.txt 8 | 9 | COPY . /usr/src/app/bot 10 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | Цей темплейт використовується для розробки Telegram ботів з використанням бібліотеки [`aiogram v3.0+`](https://github.com/aiogram/aiogram/tree/dev-3.x). 2 | 3 | ## SQLAlchemy + Alembic 4 | В коді є приклади таблички User з використанням SQLAlchemy 2.0, та скрипти для алембіку (ініціалізація алембік, створення та застосування міграцій). 5 | 6 | Але, якщо ви з цими інструментами ніколи не працювали, то зверніться до документації і дізнайтесь про ці інструменти. 7 | Також, в мене є англомовний [курс по цим інструментам на Udemy](https://www.udemy.com/course/sqlalchemy-alembic-bootcamp/?referralCode=E9099C5B5109EB747126). 8 | 9 | ![img.png](https://img-c.udemycdn.com/course/240x135/5320614_a8af_2.jpg) 10 | 11 | ### Для того, щоб почати використовувати: 12 | 1. Скопіюйте `.env.dist` в `.env` і заповніть необхідні дані. 13 | 2. Створіть нові хендлери. 14 | 3. **Docker:** 15 | 1. Можете одразу запускати проєкт із Docker, а якщо в вас його немає, то [завантажте, та встановіть](https://docs.docker.com/get-docker/). 16 | 2. Запустіть проєкт з команди `docker-compose up` 17 | 4. **Без Docker:** 18 | 1. Створіть [venv](https://docs.python.org/3/library/venv.html) 19 | 2. Встановить залежності із requirements.txt: `pip install -r requirements.txt --pre` 20 | 3. Запустіть проєкт з команди `python3 bot.py` 21 | 22 | 23 | ### Як робити та реєструвати хендлери: 24 | Створюєте модуль `you_name.py` у папці `handlers`. 25 | 26 | Створюєте роутер у `you_name.py`. 27 | ```python 28 | from aiogram import Router 29 | user_router = Router() 30 | ``` 31 | Можна робити декілька роутерів в одному модулі, та на кожний з них навішувати хендлери. 32 | Можна реєструвати хендлери декораторами: 33 | ```python 34 | @user_router.message(commands=["start"]) 35 | async def user_start(message): 36 | await message.reply("Вітаю, звичайний користувач!") 37 | ``` 38 | 39 | Заходимо у файл `handlers/__init__.py` і додаємо всі роутери в нього: 40 | ```python 41 | from .admin import admin_router 42 | from .echo import echo_router 43 | from .user import user_router 44 | 45 | ... 46 | 47 | 48 | routers_list = [ 49 | admin_router, 50 | user_router, 51 | echo_router, # echo_router must be last 52 | ] 53 | 54 | ``` 55 | ### Як додати хендлери до нашого бота: 56 | Переходимо до файлу `bot.py` та розпаковуємо наші хендлери: 57 | ```python 58 | from tgbot.handlers import routers_list 59 | 60 | ... 61 | 62 | async def main(): 63 | 64 | ... 65 | 66 | dp.include_routers(*routers_list) 67 | 68 | ... 69 | 70 | 71 | ``` 72 | 73 | ### Як запустити Базу Данних та провести свою першу міграцію: 74 | 1. Перейдіть до `.env` файлу та заповніть дані для бази даних, якщо ви не зробили цього раніше. 75 | 76 | 2. Перейдіть до файлу `docker-compose.yml` та розкоментуйте секції: `api`, `pg_database` і `volumes`, щоб розпочати роботу. 77 | 78 | 3. Перейдіть до `config.py` та виконайте `TODO` в функції `construct_sqlalchemy_url`. Також знайдіть розділ функції `load_config` і розкоментуйте рядок, що відповідає ініціалізації конфігурації бази даних `db=DbConfig.from_env(env)`, щоб активувати підключення до бази даних. 79 | 80 | 4. Тепер можемо перезапустити Docker з новими контейнерами, використовуючи команду: 81 | 82 | `docker-compose up --build.` 83 | 84 | 5. Все готово! Тепер можемо провести міграцію! 85 | Відкрийте термінал та введіть наступну команду: 86 | 87 | `docker-compose exec api alembic upgrade head` 88 | 89 | ### Туторіали з aiogram v3 90 | Відосів поки що немає, але @Groosha вже почав робити [свій підручник](https://mastergroosha.github.io/aiogram-3-guide). 91 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = infrastructure/migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 18 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to ZoneInfo() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migrations/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | sqlalchemy.url = driver://user:pass@localhost/dbname 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 76 | # hooks = ruff 77 | # ruff.type = exec 78 | # ruff.executable = %(here)s/.venv/bin/ruff 79 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 80 | 81 | # Logging configuration 82 | [loggers] 83 | keys = root,sqlalchemy,alembic 84 | 85 | [handlers] 86 | keys = console 87 | 88 | [formatters] 89 | keys = generic 90 | 91 | [logger_root] 92 | level = WARN 93 | handlers = console 94 | qualname = 95 | 96 | [logger_sqlalchemy] 97 | level = WARN 98 | handlers = 99 | qualname = sqlalchemy.engine 100 | 101 | [logger_alembic] 102 | level = INFO 103 | handlers = 104 | qualname = alembic 105 | 106 | [handler_console] 107 | class = StreamHandler 108 | args = (sys.stderr,) 109 | level = NOTSET 110 | formatter = generic 111 | 112 | [formatter_generic] 113 | format = %(levelname)-5.5s [%(name)s] %(message)s 114 | datefmt = %H:%M:%S 115 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import betterlogging as bl 5 | from aiogram import Bot, Dispatcher 6 | from aiogram.fsm.storage.memory import MemoryStorage 7 | from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder 8 | 9 | from tgbot.config import load_config, Config 10 | from tgbot.handlers import routers_list 11 | from tgbot.middlewares.config import ConfigMiddleware 12 | from tgbot.services import broadcaster 13 | 14 | 15 | async def on_startup(bot: Bot, admin_ids: list[int]): 16 | await broadcaster.broadcast(bot, admin_ids, "Бот був запущений") 17 | 18 | 19 | def register_global_middlewares(dp: Dispatcher, config: Config, session_pool=None): 20 | """ 21 | Register global middlewares for the given dispatcher. 22 | Global middlewares here are the ones that are applied to all the handlers (you specify the type of update) 23 | 24 | :param dp: The dispatcher instance. 25 | :type dp: Dispatcher 26 | :param config: The configuration object from the loaded configuration. 27 | :param session_pool: Optional session pool object for the database using SQLAlchemy. 28 | :return: None 29 | """ 30 | middleware_types = [ 31 | ConfigMiddleware(config), 32 | # DatabaseMiddleware(session_pool), 33 | ] 34 | 35 | for middleware_type in middleware_types: 36 | dp.message.outer_middleware(middleware_type) 37 | dp.callback_query.outer_middleware(middleware_type) 38 | 39 | 40 | def setup_logging(): 41 | """ 42 | Set up logging configuration for the application. 43 | 44 | This method initializes the logging configuration for the application. 45 | It sets the log level to INFO and configures a basic colorized log for 46 | output. The log format includes the filename, line number, log level, 47 | timestamp, logger name, and log message. 48 | 49 | Returns: 50 | None 51 | 52 | Example usage: 53 | setup_logging() 54 | """ 55 | log_level = logging.INFO 56 | bl.basic_colorized_config(level=log_level) 57 | 58 | logging.basicConfig( 59 | level=logging.INFO, 60 | format="%(filename)s:%(lineno)d #%(levelname)-8s [%(asctime)s] - %(name)s - %(message)s", 61 | ) 62 | logger = logging.getLogger(__name__) 63 | logger.info("Starting bot") 64 | 65 | 66 | def get_storage(config): 67 | """ 68 | Return storage based on the provided configuration. 69 | 70 | Args: 71 | config (Config): The configuration object. 72 | 73 | Returns: 74 | Storage: The storage object based on the configuration. 75 | 76 | """ 77 | if config.tg_bot.use_redis: 78 | return RedisStorage.from_url( 79 | config.redis.dsn(), 80 | key_builder=DefaultKeyBuilder(with_bot_id=True, with_destiny=True), 81 | ) 82 | else: 83 | return MemoryStorage() 84 | 85 | 86 | async def main(): 87 | setup_logging() 88 | 89 | config = load_config(".env") 90 | storage = get_storage(config) 91 | 92 | bot = Bot(token=config.tg_bot.token, parse_mode="HTML") 93 | dp = Dispatcher(storage=storage) 94 | 95 | dp.include_routers(*routers_list) 96 | 97 | register_global_middlewares(dp, config) 98 | 99 | await on_startup(bot, config.tg_bot.admin_ids) 100 | await dp.start_polling(bot) 101 | 102 | 103 | if __name__ == "__main__": 104 | try: 105 | asyncio.run(main()) 106 | except (KeyboardInterrupt, SystemExit): 107 | logging.error("Бот був вимкнений!") 108 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | 3 | services: 4 | bot: 5 | image: "bot" 6 | stop_signal: SIGINT 7 | build: 8 | context: . 9 | working_dir: "/usr/src/app/bot" 10 | volumes: 11 | - .:/usr/src/app/bot 12 | command: python3 -m bot 13 | restart: always 14 | env_file: 15 | - ".env" 16 | 17 | logging: 18 | driver: "json-file" 19 | options: 20 | max-size: "200k" 21 | max-file: "10" 22 | 23 | 24 | ## To enable postgres uncomment the following lines 25 | # http://pgconfigurator.cybertec.at/ For Postgres Configuration 26 | # pg_database: 27 | # image: postgres:13-alpine 28 | # ports: 29 | # - "5439:5432" # Change if you like! 5439 is external to container 30 | # restart: always 31 | # volumes: 32 | # - pgdata:/var/lib/postgresql/data 33 | # command: "postgres -c max_connections=150 34 | # -c shared_buffers=512MB -c effective_cache_size=1536MB 35 | # -c maintenance_work_mem=128MB -c checkpoint_completion_target=0.9 -c wal_buffers=16MB 36 | # -c default_statistics_target=100 -c random_page_cost=1.1 -c effective_io_concurrency=200 37 | # -c work_mem=3495kB -c min_wal_size=1GB -c max_wal_size=4GB -c max_worker_processes=2 38 | # -c max_parallel_workers_per_gather=1 -c max_parallel_workers=2 -c max_parallel_maintenance_workers=1" 39 | # env_file: 40 | # - '.env' 41 | # logging: 42 | # driver: "json-file" 43 | # options: 44 | # max-size: "200k" 45 | # max-file: "10" 46 | 47 | ## To enable redis cache uncomment the following lines 48 | # redis_cache: 49 | # image: redis:6.2-alpine 50 | # restart: always 51 | # command: redis-server --port $REDIS_PORT --save 20 1 --loglevel warning --requirepass $REDIS_PASSWORD 52 | # env_file: 53 | # - ".env" 54 | # volumes: 55 | # - cache:/data 56 | 57 | # api: 58 | # image: "api" 59 | # stop_signal: SIGINT 60 | # build: 61 | # context: ./infrastructure/api 62 | # dockerfile: Dockerfile 63 | # working_dir: "/usr/src/app/api" 64 | # volumes: 65 | # - .:/usr/src/app/api 66 | # command: [ "uvicorn", "infrastructure.api.app:app", "--host", "0.0.0.0", "--port", "8000" ] 67 | # restart: always 68 | # env_file: 69 | # - ".env" 70 | # logging: 71 | # driver: "json-file" 72 | # options: 73 | # max-size: "200k" 74 | # max-file: "10" 75 | 76 | # reverse-proxy: 77 | # container_name: nginx-reverse-proxy 78 | # stop_signal: SIGINT 79 | # restart: always 80 | # image: nginx:latest 81 | # ports: 82 | # - '80:80' 83 | # volumes: 84 | # - ./nginx/nginx.conf:/etc/nginx/nginx.conf 85 | 86 | 87 | ## Uncomment the following lines if you want to use a volume for the database 88 | # volumes: 89 | # pgdata: { } 90 | # cache: { } 91 | -------------------------------------------------------------------------------- /infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/infrastructure/__init__.py -------------------------------------------------------------------------------- /infrastructure/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /usr/src/app/api 4 | 5 | COPY requirements.txt /usr/src/app/api/requirements.txt 6 | 7 | RUN pip install -r /usr/src/app/api/requirements.txt --pre 8 | 9 | COPY . /usr/src/app/api 10 | -------------------------------------------------------------------------------- /infrastructure/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/infrastructure/api/__init__.py -------------------------------------------------------------------------------- /infrastructure/api/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import betterlogging as bl 4 | import fastapi 5 | from aiogram import Bot 6 | from fastapi import FastAPI 7 | from starlette.responses import JSONResponse 8 | 9 | from tgbot.config import load_config, Config 10 | 11 | app = FastAPI() 12 | log_level = logging.INFO 13 | bl.basic_colorized_config(level=log_level) 14 | log = logging.getLogger(__name__) 15 | 16 | config: Config = load_config(".env") 17 | bot = Bot(token=config.tg_bot.token) 18 | 19 | 20 | @app.post("/api") 21 | async def webhook_endpoint(request: fastapi.Request): 22 | return JSONResponse(status_code=200, content={"status": "ok"}) 23 | -------------------------------------------------------------------------------- /infrastructure/api/requirements.txt: -------------------------------------------------------------------------------- 1 | environs~=9.0 2 | uvicorn 3 | fastapi 4 | betterlogging 5 | 6 | aiogram~=3.0 7 | 8 | sqlalchemy~=2.0 9 | alembic~=1.0 10 | asyncpg 11 | 12 | -------------------------------------------------------------------------------- /infrastructure/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/infrastructure/database/__init__.py -------------------------------------------------------------------------------- /infrastructure/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .users import User 3 | -------------------------------------------------------------------------------- /infrastructure/database/models/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import DateTime 4 | from sqlalchemy.dialects.postgresql import TIMESTAMP 5 | from sqlalchemy.ext.declarative import declared_attr 6 | from sqlalchemy.orm import DeclarativeBase 7 | from sqlalchemy.orm import Mapped, mapped_column 8 | from sqlalchemy.sql.functions import func 9 | from typing_extensions import Annotated 10 | 11 | int_pk = Annotated[int, mapped_column(primary_key=True)] 12 | 13 | 14 | class Base(DeclarativeBase): 15 | pass 16 | 17 | 18 | class TableNameMixin: 19 | @declared_attr.directive 20 | def __tablename__(cls) -> str: 21 | return cls.__name__.lower() + "s" 22 | 23 | 24 | class TimestampMixin: 25 | created_at: Mapped[datetime] = mapped_column( 26 | TIMESTAMP, server_default=func.now()) 27 | -------------------------------------------------------------------------------- /infrastructure/database/models/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy import String 4 | from sqlalchemy import text, BIGINT, Boolean, true 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from .base import Base, TimestampMixin, TableNameMixin 8 | 9 | 10 | class User(Base, TimestampMixin, TableNameMixin): 11 | """ 12 | This class represents a User in the application. 13 | If you want to learn more about SQLAlchemy and Alembic, you can check out the following link to my course: 14 | https://www.udemy.com/course/sqlalchemy-alembic-bootcamp/?referralCode=E9099C5B5109EB747126 15 | 16 | Attributes: 17 | user_id (Mapped[int]): The unique identifier of the user. 18 | username (Mapped[Optional[str]]): The username of the user. 19 | full_name (Mapped[str]): The full name of the user. 20 | active (Mapped[bool]): Indicates whether the user is active or not. 21 | language (Mapped[str]): The language preference of the user. 22 | 23 | Methods: 24 | __repr__(): Returns a string representation of the User object. 25 | 26 | Inherited Attributes: 27 | Inherits from Base, TimestampMixin, and TableNameMixin classes, which provide additional attributes and functionality. 28 | 29 | Inherited Methods: 30 | Inherits methods from Base, TimestampMixin, and TableNameMixin classes, which provide additional functionality. 31 | 32 | """ 33 | user_id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=False) 34 | username: Mapped[Optional[str]] = mapped_column(String(128)) 35 | full_name: Mapped[str] = mapped_column(String(128)) 36 | active: Mapped[bool] = mapped_column(Boolean, server_default=true()) 37 | language: Mapped[str] = mapped_column(String(10), server_default=text("'en'")) 38 | 39 | def __repr__(self): 40 | return f"" 41 | -------------------------------------------------------------------------------- /infrastructure/database/repo/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | 4 | class BaseRepo: 5 | """ 6 | A class representing a base repository for handling database operations. 7 | 8 | Attributes: 9 | session (AsyncSession): The database session used by the repository. 10 | 11 | """ 12 | 13 | def __init__(self, session): 14 | self.session: AsyncSession = session 15 | -------------------------------------------------------------------------------- /infrastructure/database/repo/requests.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from infrastructure.database.repo.users import UserRepo 6 | from infrastructure.database.setup import create_engine 7 | 8 | 9 | @dataclass 10 | class RequestsRepo: 11 | """ 12 | Repository for handling database operations. This class holds all the repositories for the database models. 13 | 14 | You can add more repositories as properties to this class, so they will be easily accessible. 15 | """ 16 | 17 | session: AsyncSession 18 | 19 | @property 20 | def users(self) -> UserRepo: 21 | """ 22 | The User repository sessions are required to manage user operations. 23 | """ 24 | return UserRepo(self.session) 25 | 26 | 27 | if __name__ == "__main__": 28 | from infrastructure.database.setup import create_session_pool 29 | from tgbot.config import Config 30 | 31 | async def example_usage(config: Config): 32 | """ 33 | Example usage function for the RequestsRepo class. 34 | Use this function as a guide to understand how to utilize RequestsRepo for managing user data. 35 | Pass the config object to this function for initializing the database resources. 36 | :param config: The config object loaded from your configuration. 37 | """ 38 | engine = create_engine(config.db) 39 | session_pool = create_session_pool(engine) 40 | 41 | async with session_pool() as session: 42 | repo = RequestsRepo(session) 43 | 44 | # Replace user details with the actual values 45 | user = await repo.users.get_or_create_user( 46 | user_id=12356, 47 | full_name="John Doe", 48 | language="en", 49 | username="johndoe", 50 | ) 51 | -------------------------------------------------------------------------------- /infrastructure/database/repo/users.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy.dialects.postgresql import insert 4 | 5 | from infrastructure.database.models import User 6 | from infrastructure.database.repo.base import BaseRepo 7 | 8 | 9 | class UserRepo(BaseRepo): 10 | async def get_or_create_user( 11 | self, 12 | user_id: int, 13 | full_name: str, 14 | language: str, 15 | username: Optional[str] = None, 16 | ): 17 | """ 18 | Creates or updates a new user in the database and returns the user object. 19 | :param user_id: The user's ID. 20 | :param full_name: The user's full name. 21 | :param language: The user's language. 22 | :param username: The user's username. It's an optional parameter. 23 | :return: User object, None if there was an error while making a transaction. 24 | """ 25 | 26 | insert_stmt = ( 27 | insert(User) 28 | .values( 29 | user_id=user_id, 30 | username=username, 31 | full_name=full_name, 32 | language=language, 33 | ) 34 | .on_conflict_do_update( 35 | index_elements=[User.user_id], 36 | set_=dict( 37 | username=username, 38 | full_name=full_name, 39 | ), 40 | ) 41 | .returning(User) 42 | ) 43 | result = await self.session.execute(insert_stmt) 44 | 45 | await self.session.commit() 46 | return result.scalar_one() 47 | -------------------------------------------------------------------------------- /infrastructure/database/setup.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker 2 | 3 | from tgbot.config import DbConfig 4 | 5 | 6 | def create_engine(db: DbConfig, echo=False): 7 | engine = create_async_engine( 8 | db.construct_sqlalchemy_url(), 9 | query_cache_size=1200, 10 | pool_size=20, 11 | max_overflow=200, 12 | future=True, 13 | echo=echo, 14 | ) 15 | return engine 16 | 17 | 18 | def create_session_pool(engine): 19 | session_pool = async_sessionmaker(bind=engine, expire_on_commit=False) 20 | return session_pool 21 | -------------------------------------------------------------------------------- /infrastructure/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /infrastructure/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 infrastructure.database.models import Base 9 | from tgbot.config import load_config 10 | 11 | from alembic import context 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | if config.config_file_name is not None: 20 | fileConfig(config.config_file_name) 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | target_metadata = Base.metadata 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 | db_config = load_config(".env").db 34 | 35 | config.set_main_option( 36 | "sqlalchemy.url", 37 | db_config.construct_sqlalchemy_url(), 38 | ) 39 | 40 | def run_migrations_offline() -> None: 41 | """Run migrations in 'offline' mode. 42 | 43 | This configures the context with just a URL 44 | and not an Engine, though an Engine is acceptable 45 | here as well. By skipping the Engine creation 46 | we don't even need a DBAPI to be available. 47 | 48 | Calls to context.execute() here emit the given string to the 49 | script output. 50 | 51 | """ 52 | 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 | -------------------------------------------------------------------------------- /infrastructure/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 | -------------------------------------------------------------------------------- /infrastructure/migrations/versions/343bb188ff78_create_users_table.py: -------------------------------------------------------------------------------- 1 | """Create users table 2 | 3 | Revision ID: 343bb188ff78 4 | Revises: 5 | Create Date: 2024-02-22 08:49:09.778944 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 = '343bb188ff78' 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('user_id', sa.BIGINT(), autoincrement=False, nullable=False), 25 | sa.Column('username', sa.String(length=128), nullable=True), 26 | sa.Column('full_name', sa.String(length=128), nullable=False), 27 | sa.Column('active', sa.Boolean(), server_default=sa.text('true'), nullable=False), 28 | sa.Column('language', sa.String(length=10), server_default=sa.text("'en'"), nullable=False), 29 | sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.func.now(), nullable=False), 30 | sa.PrimaryKeyConstraint('user_id') 31 | ) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade() -> None: 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('users') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /infrastructure/some_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/infrastructure/some_api/__init__.py -------------------------------------------------------------------------------- /infrastructure/some_api/api.py: -------------------------------------------------------------------------------- 1 | from infrastructure.some_api.base import BaseClient 2 | 3 | 4 | class MyApi(BaseClient): 5 | def __init__(self, api_key: str, **kwargs): 6 | self.api_key = api_key 7 | self.base_url = "https://some-api.com" 8 | super().__init__(base_url=self.base_url) 9 | 10 | async def get_something(self, *args, **kwargs): 11 | # await self._make_request( 12 | # ... 13 | # ) 14 | return 15 | -------------------------------------------------------------------------------- /infrastructure/some_api/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import ssl 6 | from typing import TYPE_CHECKING, Any 7 | 8 | import backoff 9 | from aiohttp import ClientError, ClientSession, TCPConnector, FormData 10 | from ujson import dumps, loads 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Mapping 14 | 15 | from yarl import URL 16 | 17 | 18 | # Taken from here: https://github.com/Olegt0rr/WebServiceTemplate/blob/main/app/core/base_client.py 19 | class BaseClient: 20 | """Represents base API client.""" 21 | 22 | def __init__(self, base_url: str | URL) -> None: 23 | self._base_url = base_url 24 | self._session: ClientSession | None = None 25 | self.log = logging.getLogger(self.__class__.__name__) 26 | 27 | async def _get_session(self) -> ClientSession: 28 | """Get aiohttp session with cache.""" 29 | if self._session is None: 30 | ssl_context = ssl.SSLContext() 31 | connector = TCPConnector(ssl_context=ssl_context) 32 | self._session = ClientSession( 33 | base_url=self._base_url, 34 | connector=connector, 35 | json_serialize=dumps, 36 | ) 37 | 38 | return self._session 39 | 40 | @backoff.on_exception( 41 | backoff.expo, 42 | ClientError, 43 | max_time=60, 44 | ) 45 | async def _make_request( 46 | self, 47 | method: str, 48 | url: str | URL, 49 | params: Mapping[str, str] | None = None, 50 | json: Mapping[str, str] | None = None, 51 | headers: Mapping[str, str] | None = None, 52 | data: FormData | None = None, 53 | ) -> tuple[int, dict[str, Any]]: 54 | """Make request and return decoded json response.""" 55 | session = await self._get_session() 56 | 57 | self.log.debug( 58 | "Making request %r %r with json %r and params %r", 59 | method, 60 | url, 61 | json, 62 | params, 63 | ) 64 | async with session.request( 65 | method, url, params=params, json=json, headers=headers, data=data 66 | ) as response: 67 | status = response.status 68 | if status != 200: 69 | s = await response.text() 70 | raise ClientError(f"Got status {status} for {method} {url}: {s}") 71 | try: 72 | result = await response.json(loads=loads) 73 | except Exception as e: 74 | self.log.exception(e) 75 | self.log.info(f"{await response.text()}") 76 | result = {} 77 | 78 | self.log.debug( 79 | "Got response %r %r with status %r and json %r", 80 | method, 81 | url, 82 | status, 83 | result, 84 | ) 85 | return status, result 86 | 87 | async def close(self) -> None: 88 | """Graceful session close.""" 89 | if not self._session: 90 | self.log.debug("There's not session to close.") 91 | return 92 | 93 | if self._session.closed: 94 | self.log.debug("Session already closed.") 95 | return 96 | 97 | await self._session.close() 98 | self.log.debug("Session successfully closed.") 99 | 100 | # Wait 250 ms for the underlying SSL connections to close 101 | # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown 102 | await asyncio.sleep(0.25) 103 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 1024; 3 | } 4 | 5 | http { 6 | upstream backend { 7 | server webhook:8000; 8 | } 9 | 10 | server { 11 | listen 80; 12 | server_name YOUR_IP_OR_DOMAIN; 13 | 14 | location /webhook { 15 | proxy_pass http://backend/webhook; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram~=3.0 2 | environs~=9.0 3 | redis 4 | betterlogging 5 | 6 | # # For enabling api: 7 | # backoff 8 | # ujson 9 | # yarl 10 | 11 | # # For PostgreSQL sqlalchemy + alembic: 12 | # sqlalchemy~=2.0 13 | # alembic~=1.0 14 | # asyncpg 15 | -------------------------------------------------------------------------------- /scripts/alembic/create_alembic.sh: -------------------------------------------------------------------------------- 1 | alembic init -t async migrations 2 | -------------------------------------------------------------------------------- /scripts/alembic/create_migrations.sh: -------------------------------------------------------------------------------- 1 | read -p "Enter name of migration: " message 2 | docker-compose exec bot alembic revision --autogenerate -m "$message" 3 | -------------------------------------------------------------------------------- /scripts/alembic/run_migrations.sh: -------------------------------------------------------------------------------- 1 | docker-compose exec bot alembic upgrade head -------------------------------------------------------------------------------- /scripts/postgres/create_dump.sh: -------------------------------------------------------------------------------- 1 | # SET ENVIRONMENT VARIABLES 2 | export PG_CONTAINER_NAME=pg_database 3 | export POSTGRES_USER=someusername 4 | export POSTGRES_DB=bot 5 | docker exec -t ${PG_CONTAINER_NAME} pg_dump -U ${POSTGRES_USER} -Fp -f /tmp/db_dump.sql --dbname=${POSTGRES_DB} 6 | mkdir -p ./postgres 7 | docker cp ${PG_CONTAINER_NAME}:/tmp/db_dump.sql ./postgres/db_dump.sql 8 | -------------------------------------------------------------------------------- /tgbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/__init__.py -------------------------------------------------------------------------------- /tgbot/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from environs import Env 5 | 6 | 7 | @dataclass 8 | class DbConfig: 9 | """ 10 | Database configuration class. 11 | This class holds the settings for the database, such as host, password, port, etc. 12 | 13 | Attributes 14 | ---------- 15 | host : str 16 | The host where the database server is located. 17 | password : str 18 | The password used to authenticate with the database. 19 | user : str 20 | The username used to authenticate with the database. 21 | database : str 22 | The name of the database. 23 | port : int 24 | The port where the database server is listening. 25 | """ 26 | 27 | host: str 28 | password: str 29 | user: str 30 | database: str 31 | port: int = 5432 32 | 33 | # For SQLAlchemy 34 | def construct_sqlalchemy_url(self, driver="asyncpg", host=None, port=None) -> str: 35 | """ 36 | Constructs and returns a SQLAlchemy URL for this database configuration. 37 | """ 38 | # TODO: If you're using SQLAlchemy, move the import to the top of the file! 39 | from sqlalchemy.engine.url import URL 40 | 41 | if not host: 42 | host = self.host 43 | if not port: 44 | port = self.port 45 | uri = URL.create( 46 | drivername=f"postgresql+{driver}", 47 | username=self.user, 48 | password=self.password, 49 | host=host, 50 | port=port, 51 | database=self.database, 52 | ) 53 | return uri.render_as_string(hide_password=False) 54 | 55 | @staticmethod 56 | def from_env(env: Env): 57 | """ 58 | Creates the DbConfig object from environment variables. 59 | """ 60 | host = env.str("DB_HOST") 61 | password = env.str("POSTGRES_PASSWORD") 62 | user = env.str("POSTGRES_USER") 63 | database = env.str("POSTGRES_DB") 64 | port = env.int("DB_PORT", 5432) 65 | return DbConfig( 66 | host=host, password=password, user=user, database=database, port=port 67 | ) 68 | 69 | 70 | @dataclass 71 | class TgBot: 72 | """ 73 | Creates the TgBot object from environment variables. 74 | """ 75 | 76 | token: str 77 | admin_ids: list[int] 78 | use_redis: bool 79 | 80 | @staticmethod 81 | def from_env(env: Env): 82 | """ 83 | Creates the TgBot object from environment variables. 84 | """ 85 | token = env.str("BOT_TOKEN") 86 | admin_ids = env.list("ADMINS", subcast=int) 87 | use_redis = env.bool("USE_REDIS") 88 | return TgBot(token=token, admin_ids=admin_ids, use_redis=use_redis) 89 | 90 | 91 | @dataclass 92 | class RedisConfig: 93 | """ 94 | Redis configuration class. 95 | 96 | Attributes 97 | ---------- 98 | redis_pass : Optional(str) 99 | The password used to authenticate with Redis. 100 | redis_port : Optional(int) 101 | The port where Redis server is listening. 102 | redis_host : Optional(str) 103 | The host where Redis server is located. 104 | """ 105 | 106 | redis_pass: Optional[str] 107 | redis_port: Optional[int] 108 | redis_host: Optional[str] 109 | 110 | def dsn(self) -> str: 111 | """ 112 | Constructs and returns a Redis DSN (Data Source Name) for this database configuration. 113 | """ 114 | if self.redis_pass: 115 | return f"redis://:{self.redis_pass}@{self.redis_host}:{self.redis_port}/0" 116 | else: 117 | return f"redis://{self.redis_host}:{self.redis_port}/0" 118 | 119 | @staticmethod 120 | def from_env(env: Env): 121 | """ 122 | Creates the RedisConfig object from environment variables. 123 | """ 124 | redis_pass = env.str("REDIS_PASSWORD") 125 | redis_port = env.int("REDIS_PORT") 126 | redis_host = env.str("REDIS_HOST") 127 | 128 | return RedisConfig( 129 | redis_pass=redis_pass, redis_port=redis_port, redis_host=redis_host 130 | ) 131 | 132 | 133 | @dataclass 134 | class Miscellaneous: 135 | """ 136 | Miscellaneous configuration class. 137 | 138 | This class holds settings for various other parameters. 139 | It merely serves as a placeholder for settings that are not part of other categories. 140 | 141 | Attributes 142 | ---------- 143 | other_params : str, optional 144 | A string used to hold other various parameters as required (default is None). 145 | """ 146 | 147 | other_params: str = None 148 | 149 | 150 | @dataclass 151 | class Config: 152 | """ 153 | The main configuration class that integrates all the other configuration classes. 154 | 155 | This class holds the other configuration classes, providing a centralized point of access for all settings. 156 | 157 | Attributes 158 | ---------- 159 | tg_bot : TgBot 160 | Holds the settings related to the Telegram Bot. 161 | misc : Miscellaneous 162 | Holds the values for miscellaneous settings. 163 | db : Optional[DbConfig] 164 | Holds the settings specific to the database (default is None). 165 | redis : Optional[RedisConfig] 166 | Holds the settings specific to Redis (default is None). 167 | """ 168 | 169 | tg_bot: TgBot 170 | misc: Miscellaneous 171 | db: Optional[DbConfig] = None 172 | redis: Optional[RedisConfig] = None 173 | 174 | 175 | def load_config(path: str = None) -> Config: 176 | """ 177 | This function takes an optional file path as input and returns a Config object. 178 | :param path: The path of env file from where to load the configuration variables. 179 | It reads environment variables from a .env file if provided, else from the process environment. 180 | :return: Config object with attributes set as per environment variables. 181 | """ 182 | 183 | # Create an Env object. 184 | # The Env object will be used to read environment variables. 185 | env = Env() 186 | env.read_env(path) 187 | 188 | return Config( 189 | tg_bot=TgBot.from_env(env), 190 | # db=DbConfig.from_env(env), 191 | # redis=RedisConfig.from_env(env), 192 | misc=Miscellaneous(), 193 | ) 194 | -------------------------------------------------------------------------------- /tgbot/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/filters/__init__.py -------------------------------------------------------------------------------- /tgbot/filters/admin.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters import BaseFilter 2 | from aiogram.types import Message 3 | 4 | from tgbot.config import Config 5 | 6 | 7 | class AdminFilter(BaseFilter): 8 | is_admin: bool = True 9 | 10 | async def __call__(self, obj: Message, config: Config) -> bool: 11 | return (obj.from_user.id in config.tg_bot.admin_ids) == self.is_admin 12 | -------------------------------------------------------------------------------- /tgbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | """Import all routers and add them to routers_list.""" 2 | from .admin import admin_router 3 | from .echo import echo_router 4 | from .simple_menu import menu_router 5 | from .user import user_router 6 | 7 | routers_list = [ 8 | admin_router, 9 | menu_router, 10 | user_router, 11 | echo_router, # echo_router must be last 12 | ] 13 | 14 | __all__ = [ 15 | "routers_list", 16 | ] 17 | -------------------------------------------------------------------------------- /tgbot/handlers/admin.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | from tgbot.filters.admin import AdminFilter 6 | 7 | admin_router = Router() 8 | admin_router.message.filter(AdminFilter()) 9 | 10 | 11 | @admin_router.message(CommandStart()) 12 | async def admin_start(message: Message): 13 | await message.reply("Вітаю, адміне!") 14 | -------------------------------------------------------------------------------- /tgbot/handlers/echo.py: -------------------------------------------------------------------------------- 1 | from aiogram import types, Router, F 2 | from aiogram.filters import StateFilter 3 | from aiogram.fsm.context import FSMContext 4 | from aiogram.utils.markdown import hcode 5 | 6 | echo_router = Router() 7 | 8 | 9 | @echo_router.message(F.text, StateFilter(None)) 10 | async def bot_echo(message: types.Message): 11 | text = ["Ехо без стану.", "Повідомлення:", message.text] 12 | 13 | await message.answer("\n".join(text)) 14 | 15 | 16 | @echo_router.message(F.text) 17 | async def bot_echo_all(message: types.Message, state: FSMContext): 18 | state_name = await state.get_state() 19 | text = [ 20 | f"Ехо у стані {hcode(state_name)}", 21 | "Зміст повідомлення:", 22 | hcode(message.text), 23 | ] 24 | await message.answer("\n".join(text)) 25 | -------------------------------------------------------------------------------- /tgbot/handlers/simple_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, F 2 | from aiogram.enums import ParseMode 3 | from aiogram.filters import Command 4 | from aiogram.types import Message, CallbackQuery 5 | from aiogram.utils.formatting import as_section, as_key_value, as_marked_list 6 | 7 | from tgbot.keyboards.inline import simple_menu_keyboard, my_orders_keyboard, \ 8 | OrderCallbackData 9 | 10 | menu_router = Router() 11 | 12 | 13 | @menu_router.message(Command("menu")) 14 | async def show_menu(message: Message): 15 | await message.answer("Виберіть пункт меню:", reply_markup=simple_menu_keyboard()) 16 | 17 | 18 | # We can use F.data filter to filter callback queries by data field from CallbackQuery object 19 | @menu_router.callback_query(F.data == "create_order") 20 | async def create_order(query: CallbackQuery): 21 | # Firstly, always answer callback query (as Telegram API requires) 22 | await query.answer() 23 | 24 | # This method will send an answer to the message with the button, that user pressed 25 | # Here query - is a CallbackQuery object, which contains message: Message object 26 | await query.message.answer("Ви обрали створення замовлення!") 27 | 28 | # You can also Edit the message with a new text 29 | # await query.message.edit_text("Ви обрали створення замовлення!") 30 | 31 | 32 | # Let's create a simple list of orders for demonstration purposes 33 | ORDERS = [ 34 | {"id": 1, "title": "Замовлення 1", "status": "Виконується"}, 35 | {"id": 2, "title": "Замовлення 2", "status": "Виконано"}, 36 | {"id": 3, "title": "Замовлення 3", "status": "Виконано"}, 37 | ] 38 | 39 | 40 | @menu_router.callback_query(F.data == "my_orders") 41 | async def my_orders(query: CallbackQuery): 42 | await query.answer() 43 | await query.message.edit_text("Ви обрали перегляд ваших замовлень!", 44 | reply_markup=my_orders_keyboard(ORDERS)) 45 | 46 | 47 | # To filter the callback data, that was created with CallbackData factory, you can use .filter() method 48 | @menu_router.callback_query(OrderCallbackData.filter()) 49 | async def show_order(query: CallbackQuery, callback_data: OrderCallbackData): 50 | await query.answer() 51 | 52 | # You can get the data from callback_data object as attributes 53 | order_id = callback_data.order_id 54 | 55 | # Then you can get the order from your database (here we use a simple list) 56 | order_info = next((order for order in ORDERS if order["id"] == order_id), None) 57 | 58 | if order_info: 59 | # Here we use aiogram.utils.formatting to format the text 60 | # https://docs.aiogram.dev/en/latest/utils/formatting.html 61 | text = as_section( 62 | as_key_value("Замовлення #", order_info["id"]), 63 | as_marked_list( 64 | as_key_value("Товар", order_info["title"]), 65 | as_key_value("Статус", order_info["status"]), 66 | ), 67 | ) 68 | # Example: 69 | # Замовлення #: 2 70 | # - Товар: Замовлення 2 71 | # - Статус: Виконано 72 | 73 | await query.message.edit_text(text.as_html(), parse_mode=ParseMode.HTML) 74 | 75 | # You can also use MarkdownV2: 76 | # await query.message.edit_text(text.as_markdown(), parse_mode=ParseMode.MARKDOWN_V2) 77 | else: 78 | await query.message.edit_text("Замовлення не знайдено!") 79 | -------------------------------------------------------------------------------- /tgbot/handlers/user.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters import CommandStart 3 | from aiogram.types import Message 4 | 5 | user_router = Router() 6 | 7 | 8 | @user_router.message(CommandStart()) 9 | async def user_start(message: Message): 10 | await message.reply("Вітаю, звичайний користувач!") 11 | -------------------------------------------------------------------------------- /tgbot/keyboards/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/keyboards/__init__.py -------------------------------------------------------------------------------- /tgbot/keyboards/inline.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | 6 | # This is a simple keyboard, that contains 2 buttons 7 | def very_simple_keyboard(): 8 | buttons = [ 9 | [ 10 | InlineKeyboardButton(text="📝 Створити замовлення", 11 | callback_data="create_order"), 12 | InlineKeyboardButton(text="📋 Мої замовлення", callback_data="my_orders"), 13 | ], 14 | ] 15 | 16 | keyboard = InlineKeyboardMarkup( 17 | inline_keyboard=buttons, 18 | ) 19 | return keyboard 20 | 21 | 22 | # This is the same keyboard, but created with InlineKeyboardBuilder (preferred way) 23 | def simple_menu_keyboard(): 24 | # First, you should create an InlineKeyboardBuilder object 25 | keyboard = InlineKeyboardBuilder() 26 | 27 | # You can use keyboard.button() method to add buttons, then enter text and callback_data 28 | keyboard.button( 29 | text="📝 Створити замовлення", 30 | callback_data="create_order" 31 | ) 32 | keyboard.button( 33 | text="📋 Мої замовлення", 34 | # In this simple example, we use a string as callback_data 35 | callback_data="my_orders" 36 | ) 37 | 38 | # If needed you can use keyboard.adjust() method to change the number of buttons per row 39 | # keyboard.adjust(2) 40 | 41 | # Then you should always call keyboard.as_markup() method to get a valid InlineKeyboardMarkup object 42 | return keyboard.as_markup() 43 | 44 | 45 | # For a more advanced usage of callback_data, you can use the CallbackData factory 46 | class OrderCallbackData(CallbackData, prefix="order"): 47 | """ 48 | This class represents a CallbackData object for orders. 49 | 50 | - When used in InlineKeyboardMarkup, you have to create an instance of this class, run .pack() method, and pass to callback_data parameter. 51 | 52 | - When used in InlineKeyboardBuilder, you have to create an instance of this class and pass to callback_data parameter (without .pack() method). 53 | 54 | - In handlers you have to import this class and use it as a filter for callback query handlers, and then unpack callback_data parameter to get the data. 55 | 56 | # Example usage in simple_menu.py 57 | """ 58 | order_id: int 59 | 60 | 61 | def my_orders_keyboard(orders: list): 62 | # Here we use a list of orders as a parameter (from simple_menu.py) 63 | 64 | keyboard = InlineKeyboardBuilder() 65 | for order in orders: 66 | keyboard.button( 67 | text=f"📝 {order['title']}", 68 | # Here we use an instance of OrderCallbackData class as callback_data parameter 69 | # order id is the field in OrderCallbackData class, that we defined above 70 | callback_data=OrderCallbackData(order_id=order["id"]) 71 | ) 72 | 73 | return keyboard.as_markup() 74 | -------------------------------------------------------------------------------- /tgbot/keyboards/reply.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/keyboards/reply.py -------------------------------------------------------------------------------- /tgbot/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/middlewares/__init__.py -------------------------------------------------------------------------------- /tgbot/middlewares/config.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, Any, Awaitable 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Message 5 | 6 | 7 | class ConfigMiddleware(BaseMiddleware): 8 | def __init__(self, config) -> None: 9 | self.config = config 10 | 11 | async def __call__( 12 | self, 13 | handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], 14 | event: Message, 15 | data: Dict[str, Any], 16 | ) -> Any: 17 | data["config"] = self.config 18 | return await handler(event, data) 19 | -------------------------------------------------------------------------------- /tgbot/middlewares/database.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Dict, Any, Awaitable 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import Message 5 | 6 | from infrastructure.database.repo.requests import RequestsRepo 7 | 8 | 9 | class DatabaseMiddleware(BaseMiddleware): 10 | def __init__(self, session_pool) -> None: 11 | self.session_pool = session_pool 12 | 13 | async def __call__( 14 | self, 15 | handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], 16 | event: Message, 17 | data: Dict[str, Any], 18 | ) -> Any: 19 | async with self.session_pool() as session: 20 | repo = RequestsRepo(session) 21 | 22 | user = await repo.users.get_or_create_user( 23 | event.from_user.id, 24 | event.from_user.full_name, 25 | event.from_user.language_code, 26 | event.from_user.username 27 | ) 28 | 29 | data["session"] = session 30 | data["repo"] = repo 31 | data["user"] = user 32 | 33 | result = await handler(event, data) 34 | return result 35 | -------------------------------------------------------------------------------- /tgbot/misc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/misc/__init__.py -------------------------------------------------------------------------------- /tgbot/misc/states.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/misc/states.py -------------------------------------------------------------------------------- /tgbot/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Latand/tgbot_template_v3/16edcd192c07b8de1dadea4d68b4704add094764/tgbot/services/__init__.py -------------------------------------------------------------------------------- /tgbot/services/broadcaster.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Union 4 | 5 | from aiogram import Bot 6 | from aiogram import exceptions 7 | from aiogram.types import InlineKeyboardMarkup 8 | 9 | 10 | async def send_message( 11 | bot: Bot, 12 | user_id: Union[int, str], 13 | text: str, 14 | disable_notification: bool = False, 15 | reply_markup: InlineKeyboardMarkup = None, 16 | ) -> bool: 17 | """ 18 | Safe messages sender 19 | 20 | :param bot: Bot instance. 21 | :param user_id: user id. If str - must contain only digits. 22 | :param text: text of the message. 23 | :param disable_notification: disable notification or not. 24 | :param reply_markup: reply markup. 25 | :return: success. 26 | """ 27 | try: 28 | await bot.send_message( 29 | user_id, 30 | text, 31 | disable_notification=disable_notification, 32 | reply_markup=reply_markup, 33 | ) 34 | except exceptions.TelegramBadRequest as e: 35 | logging.error("Telegram server says - Bad Request: chat not found") 36 | except exceptions.TelegramForbiddenError: 37 | logging.error(f"Target [ID:{user_id}]: got TelegramForbiddenError") 38 | except exceptions.TelegramRetryAfter as e: 39 | logging.error( 40 | f"Target [ID:{user_id}]: Flood limit is exceeded. Sleep {e.retry_after} seconds." 41 | ) 42 | await asyncio.sleep(e.retry_after) 43 | return await send_message( 44 | bot, user_id, text, disable_notification, reply_markup 45 | ) # Recursive call 46 | except exceptions.TelegramAPIError: 47 | logging.exception(f"Target [ID:{user_id}]: failed") 48 | else: 49 | logging.info(f"Target [ID:{user_id}]: success") 50 | return True 51 | return False 52 | 53 | 54 | async def broadcast( 55 | bot: Bot, 56 | users: list[Union[str, int]], 57 | text: str, 58 | disable_notification: bool = False, 59 | reply_markup: InlineKeyboardMarkup = None, 60 | ) -> int: 61 | """ 62 | Simple broadcaster. 63 | :param bot: Bot instance. 64 | :param users: List of users. 65 | :param text: Text of the message. 66 | :param disable_notification: Disable notification or not. 67 | :param reply_markup: Reply markup. 68 | :return: Count of messages. 69 | """ 70 | count = 0 71 | try: 72 | for user_id in users: 73 | if await send_message( 74 | bot, user_id, text, disable_notification, reply_markup 75 | ): 76 | count += 1 77 | await asyncio.sleep( 78 | 0.05 79 | ) # 20 messages per second (Limit: 30 messages per second) 80 | finally: 81 | logging.info(f"{count} messages successful sent.") 82 | 83 | return count 84 | --------------------------------------------------------------------------------