├── .env.example ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── app ├── bot │ ├── .dockerignore │ ├── Dockerfile │ ├── __init__.py │ ├── errors │ │ ├── __init__.py │ │ ├── aiogram_errors.py │ │ └── errors.py │ ├── filters │ │ ├── __init__.py │ │ ├── cb_click_by_user.py │ │ └── lazy_filter.py │ ├── handlers │ │ ├── __init__.py │ │ ├── cbs │ │ │ ├── __init__.py │ │ │ ├── language_settings │ │ │ │ ├── __init__.py │ │ │ │ ├── keyboards.py │ │ │ │ └── main.py │ │ │ ├── start.py │ │ │ └── universal_close.py │ │ ├── chat_member │ │ │ ├── __init__.py │ │ │ ├── any_to_administrator.py │ │ │ ├── any_to_kicked.py │ │ │ ├── any_to_left.py │ │ │ ├── any_to_member.py │ │ │ ├── any_to_restricted.py │ │ │ ├── any_to_unhandled.py │ │ │ └── my_chat_member │ │ │ │ ├── __init__.py │ │ │ │ ├── groups.py │ │ │ │ └── private.py │ │ ├── chat_migrate.py │ │ └── cmds │ │ │ ├── __init__.py │ │ │ ├── language_settings.py │ │ │ └── start.py │ ├── locales │ │ ├── en │ │ │ ├── _default.ftl │ │ │ ├── chat_member │ │ │ │ ├── chat_member.ftl │ │ │ │ └── my_chat_member.ftl │ │ │ └── cmds │ │ │ │ ├── start.ftl │ │ │ │ └── user_settings.ftl │ │ └── uk │ │ │ ├── _default.ftl │ │ │ ├── chat_member │ │ │ ├── chat_member.ftl │ │ │ └── my_chat_member.ftl │ │ │ └── cmds │ │ │ ├── start.ftl │ │ │ └── user_settings.ftl │ ├── main.py │ ├── middlewares │ │ ├── __init__.py │ │ ├── check_chat_middleware.py │ │ ├── check_user_middleware.py │ │ └── throttling_middleware.py │ ├── pyproject.toml │ ├── settings.py │ ├── storages │ │ ├── __init__.py │ │ ├── psql │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── chat │ │ │ │ ├── __init__.py │ │ │ │ ├── chat_model.py │ │ │ │ └── chat_settings_model.py │ │ │ ├── user │ │ │ │ ├── __init__.py │ │ │ │ ├── user_model.py │ │ │ │ └── user_settings_model.py │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ └── alchemy_struct.py │ │ └── redis │ │ │ ├── __init__.py │ │ │ ├── chat │ │ │ ├── __init__.py │ │ │ ├── chat_model.py │ │ │ └── chat_settings_model.py │ │ │ ├── chat_member │ │ │ ├── __init__.py │ │ │ └── chat_member_model.py │ │ │ └── user │ │ │ ├── __init__.py │ │ │ ├── user_model.py │ │ │ └── user_settings_model.py │ ├── stub.pyi │ └── utils │ │ ├── __init__.py │ │ ├── callback_data_prefix_enums.py │ │ └── fsm_manager.py └── migrations │ ├── .dockerignore │ ├── Dockerfile │ ├── README │ ├── __init__.py │ ├── alembic.ini │ ├── env.py │ ├── pyproject.toml │ ├── script.py.mako │ └── versions │ ├── 000000000000_initial.py │ └── __init__.py ├── caddy ├── Caddyfile └── public │ ├── favicon.ico │ └── index.html ├── docker-compose.yml ├── psql └── db-init-scripts │ └── citext.sh ├── pyproject.toml └── uv.lock /.env.example: -------------------------------------------------------------------------------- 1 | DEV=True 2 | DEVELOPER_ID=123456789 3 | WEBHOOKS=False 4 | TEST_SERVER=False 5 | 6 | BOT_TOKEN=123456789:AAA-AAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAA 7 | WEBHOOK_URL=https://example.com/webhook 8 | WEBHOOK_SECRET_TOKEN=secret 9 | 10 | # Postgres 11 | PSQL_HOST=database 12 | PSQL_PORT=5432 13 | PSQL_USER=template_bot 14 | PSQL_PASSWORD=password 15 | PSQL_DB=template_db 16 | PSQL_EXTERNAL_PORT=15432 17 | 18 | # Redis 19 | REDIS_HOST=redis 20 | REDIS_PORT=6379 21 | REDIS_USER=default 22 | REDIS_PASSWORD=password 23 | REDIS_DB=0 24 | REDIS_EXTERNAL_PORT=16379 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.mo 2 | *.pyc 3 | .env 4 | .env.* 5 | /.idea/ 6 | /.ruff_cache/ 7 | /.venv/ 8 | /caddy/config/caddy/ 9 | /caddy/data/caddy/ 10 | /psql/data/ 11 | /redis/data/ 12 | __pycache__/ 13 | **/*.log 14 | stub.json 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: false 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: "trailing-whitespace" 7 | - id: "check-case-conflict" 8 | - id: "check-merge-conflict" 9 | - id: "debug-statements" 10 | - id: "end-of-file-fixer" 11 | - id: "mixed-line-ending" 12 | args: [ "--fix", "crlf" ] 13 | types: 14 | - python 15 | - yaml 16 | - toml 17 | - text 18 | - id: "detect-private-key" 19 | - id: "check-yaml" 20 | - id: "check-toml" 21 | - id: "check-json" 22 | 23 | - repo: https://github.com/charliermarsh/ruff-pre-commit 24 | rev: v0.12.10 25 | hooks: 26 | - id: ruff 27 | args: [ "--fix" ] 28 | files: "app" 29 | 30 | - id: ruff-format 31 | files: "app" 32 | 33 | - repo: https://github.com/pycqa/isort 34 | rev: 6.0.1 35 | hooks: 36 | - id: isort 37 | name: isort (python) 38 | files: "app" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Andrew 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .env 2 | export 3 | 4 | ifeq ($(OS),Windows_NT) 5 | DETECTED_OS := Windows 6 | else 7 | DETECTED_OS := $(shell uname -s) 8 | endif 9 | 10 | app-dir = app 11 | bot-dir = bot 12 | 13 | .PHONY: up 14 | up: 15 | ifeq ($(DETECTED_OS),Windows) 16 | docker compose \ 17 | --env-file .env.docker \ 18 | --file docker-compose.yml \ 19 | up \ 20 | -d \ 21 | --build \ 22 | --timeout 60 \ 23 | bot 24 | else 25 | docker compose \ 26 | --env-file .env.docker \ 27 | --file docker-compose.yml \ 28 | build \ 29 | --build-arg USER_ID=$(SUDO_UID) \ 30 | --build-arg GROUP_ID=$(SUDO_GID) \ 31 | --build-arg USER_NAME=$(SUDO_USER) \ 32 | bot 33 | 34 | docker compose \ 35 | --env-file .env.docker \ 36 | --file docker-compose.yml \ 37 | up \ 38 | -d \ 39 | --timeout 60 \ 40 | bot 41 | endif 42 | 43 | .PHONY: up-db 44 | up-db: 45 | ifeq ($(DETECTED_OS),Windows) 46 | docker compose \ 47 | --env-file .env.docker \ 48 | --file docker-compose.yml \ 49 | up \ 50 | -d \ 51 | --build \ 52 | --timeout 60 \ 53 | database redis 54 | else 55 | docker compose \ 56 | --env-file .env.docker \ 57 | --file docker-compose.yml \ 58 | build \ 59 | --build-arg USER_ID=$(SUDO_UID) \ 60 | --build-arg GROUP_ID=$(SUDO_GID) \ 61 | --build-arg USER_NAME=$(SUDO_USER) \ 62 | database redis 63 | 64 | docker compose \ 65 | --env-file .env.docker \ 66 | --file docker-compose.yml \ 67 | up \ 68 | -d \ 69 | --timeout 60 \ 70 | database redis 71 | endif 72 | 73 | .PHONY: build 74 | build: 75 | ifeq ($(DETECTED_OS),Windows) 76 | docker compose \ 77 | --env-file .env.docker \ 78 | --file docker-compose.yml \ 79 | up \ 80 | -d \ 81 | --build \ 82 | --timeout 60 \ 83 | bot migrations 84 | else 85 | docker compose \ 86 | --env-file .env.docker \ 87 | --file docker-compose.yml \ 88 | build \ 89 | --build-arg USER_ID=$(SUDO_UID) \ 90 | --build-arg GROUP_ID=$(SUDO_GID) \ 91 | --build-arg USER_NAME=$(SUDO_USER) \ 92 | bot migrations 93 | endif 94 | 95 | .PHONY: down 96 | down: 97 | docker compose --env-file .env.docker --file docker-compose.yml down --timeout 60 98 | 99 | .PHONY: pull 100 | pull: 101 | git pull origin master 102 | git submodule update --init --recursive 103 | 104 | .PHONY: extract-locales 105 | extract-locales: 106 | uv run fast-ftl-extract \ 107 | './app/bot' \ 108 | './app/bot/locales' \ 109 | -l 'en' \ 110 | -l 'uk' \ 111 | -K 'LF' \ 112 | -I 'core' \ 113 | --comment-junks \ 114 | --comment-keys-mode 'comment' \ 115 | --verbose 116 | 117 | .PHONY: stub 118 | stub: 119 | uv run ftl stub \ 120 | './app/bot/locales/en' \ 121 | './app/bot' 122 | 123 | .PHONY: lint 124 | lint: 125 | echo "Running ruff..." 126 | uv run ruff check --config pyproject.toml --diff $(app-dir) 127 | 128 | .PHONY: format 129 | format: 130 | echo "Running ruff check with --fix..." 131 | uv run ruff check --config pyproject.toml --fix --unsafe-fixes $(app-dir) 132 | 133 | echo "Running ruff..." 134 | uv run ruff format --config pyproject.toml $(app-dir) 135 | 136 | echo "Running isort..." 137 | uv run isort --settings-file pyproject.toml $(app-dir) 138 | 139 | .PHONY: mypy 140 | mypy: 141 | echo "Running MyPy..." 142 | uv run mypy --config-file pyproject.toml --explicit-package-bases $(app-dir)/$(bot-dir) 143 | 144 | .PHONY: outdated 145 | outdated: 146 | uv tree --outdated --universal 147 | 148 | .PHONY: sync 149 | sync: 150 | uv sync --extra dev --extra lint 151 | 152 | .PHONY: create-revision 153 | create-revision: build 154 | docker compose \ 155 | --env-file .env.docker \ 156 | --file docker-compose.yml \ 157 | run \ 158 | --rm \ 159 | migrations \ 160 | bash -c ".venv/bin/alembic --config alembic.ini revision --autogenerate -m '$(message)'" 161 | 162 | .PHONY: upgrade-revision 163 | upgrade-revision: build 164 | docker compose \ 165 | --env-file .env.docker \ 166 | --file docker-compose.yml \ 167 | run \ 168 | --rm \ 169 | migrations \ 170 | bash -c ".venv/bin/alembic --config alembic.ini upgrade $(revision)" 171 | 172 | .PHONY: downgrade-revision 173 | downgrade-revision: build 174 | docker compose \ 175 | --env-file .env.docker \ 176 | --file docker-compose.yml \ 177 | run \ 178 | --rm \ 179 | migrations \ 180 | bash -c ".venv/bin/alembic --config alembic.ini downgrade $(revision)" 181 | 182 | .PHONY: current-revision 183 | current-revision: build 184 | docker compose \ 185 | --env-file .env.docker \ 186 | --file docker-compose.yml \ 187 | run \ 188 | --rm \ 189 | migrations \ 190 | bash -c ".venv/bin/alembic --config alembic.ini current" 191 | 192 | .PHONY: create-init-revision 193 | create-init-revision: build 194 | docker compose \ 195 | --env-file .env.docker \ 196 | --file docker-compose.yml \ 197 | run \ 198 | --rm \ 199 | migrations \ 200 | bash -c ".venv/bin/alembic --config alembic.ini revision --autogenerate -m 'Initial' --rev-id 000000000000" 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiogram-template 2 | 3 | This is a template for creating Telegram bots using the aiogram library. 4 | 5 | ### Template uses: 6 | 7 | * SQLAlchemy + Alembic 8 | * PostgreSQL 9 | * Redis 10 | * Caddy Server 11 | * Docker 12 | * i18n (Project Fluent) 13 | * uv 14 | 15 | *** 16 | 17 | ## Installation 18 | 19 | ### Step 1: Clone the repository 20 | 21 | ```shell 22 | git clone https://github.com/andrew000/aiogram-template.git 23 | cd aiogram-template 24 | ``` 25 | 26 | ### Step 2: Install dependencies 27 | 28 | I recommend using [UV](https://docs.astral.sh/uv/) to manage your project. 29 | 30 | ```shell 31 | # Create virtual environment using UV 32 | uv venv --python=3.13 33 | 34 | # Install dependencies 35 | make sync 36 | ``` 37 | 38 | ### Step 3: Create `.env` file 39 | 40 | Create a `.env` file in the root of the project and fill it with the necessary data. 41 | 42 | ```shell 43 | cp .env.example .env.docker # for docker development 44 | cp .env.example .env # for local development 45 | ``` 46 | 47 | ### Step 4: Deploy project 48 | 49 | ```shell 50 | make up 51 | ``` 52 | 53 | ### Step 5: Run migrations 54 | 55 | Template already has initial migration. To apply it, run the following command: 56 | 57 | ```shell 58 | make upgrade-revision revision=head 59 | ``` 60 | 61 | ### Step 6: Bot is ready and running 62 | 63 | Bot is ready to use. You can check the logs using the following command: 64 | 65 | ```shell 66 | docker compose logs -f 67 | ``` 68 | 69 | *** 70 | 71 | ## Explanation 72 | 73 | ### Project structure 74 | 75 | The project structure is as follows: 76 | 77 | ``` 78 | AIOGRAM-TEMPLATE 79 | ├───app (main application) 80 | │ ├───bot (bot) 81 | │ ├───migrations (alembic migrations) 82 | ├───├───pyproject.toml (application configuration) 83 | │ ├───Dockerfile-bot (Dockerfile for the bot) 84 | │ └───Dockerfile-migrations (Dockerfile for the migrations) 85 | ├───caddy (Caddy web server) 86 | ├───psql (PostgreSQL database) 87 | │ ├───data (database data) 88 | │ └───db-init-script (database initialization script) 89 | ├───redis (Redis database) 90 | │ └───data (redis data) 91 | ├───pyproject.toml (project configuration) 92 | ├───docker-compose.yml (docker-compose configuration) 93 | ├───.env.example (example environment file) 94 | ├───.pre-commit-config.yaml (pre-commit configuration) 95 | └───Makefile (make commands) 96 | ``` 97 | 98 | The bot is located in the `app/bot` directory. The bot is divided into modules, each of which is responsible for a 99 | specific functionality. `handlers` are responsible for processing events, `middlewares` for preprocessing events, 100 | `storages` for declaring models and working with the database, `locales` for localization, `filters` for own filters, 101 | `errors` for error handling. 102 | 103 | ### Migrations 104 | 105 | Migration files are located in the `app/migrations` directory. 106 | 107 | ❗️ It is recommended to create migrations files before you push your code to the repository. 108 | 109 | ❗️ Always check your migrations before apply them to the production database. 110 | 111 | To create initial migration, check if your models imported in the `app/bot/storages/psql/__init__.py` file and run the 112 | following command: 113 | 114 | ```shell 115 | make create-init-revision 116 | ``` 117 | 118 | To apply `head` migration, run the following command: 119 | ```shell 120 | make upgrade-revision revision=head 121 | ``` 122 | 123 | To apply specific migration, run the following command: 124 | 125 | ```shell 126 | make upgrade-revision revision= 127 | ``` 128 | 129 | `revision_id` - id of the migration in the `app/migrations/versions` directory. Initial migration id is 130 | `000000000000`. 131 | 132 | To check current migration `revision_id` in the database, run the following command: 133 | 134 | ```shell 135 | make current-revision 136 | ``` 137 | 138 | ### Localization 139 | 140 | The Bot supports localization. Localization files are located in the `app/bot/locales` directory. The bot uses the 141 | `aiogram-i18n` library for localization and `FTL-Extract` for extracting FTL-keys from the code. 142 | 143 | To extract FTL-keys from the code, run the following command: 144 | 145 | ```shell 146 | make extract-locales 147 | ``` 148 | 149 | After extracting FTL-keys, you can find new directories and files in the `app/bot/locales` directory. To add or remove 150 | locales for extraction, edit `Makefile` 151 | 152 | I recommend to make a submodule from `app/bot/locales` directory. It will allow you to control locales versions and 153 | publish them (without code exposing) for translations help by other people. 154 | 155 | ### Pre-commit 156 | 157 | The project uses pre-commit hooks. To install pre-commit hooks, run the following command: 158 | 159 | ```shell 160 | uv run pre-commit install 161 | ``` 162 | 163 | ### Docker 164 | 165 | The project uses Docker for deployment. To build and run the bot in Docker, run the following command: 166 | 167 | ```shell 168 | make up 169 | ``` 170 | 171 | Yes, little command to run large project. It will build and run the bot, PostgreSQL, Redis, and Caddy containers. 172 | 173 | To gracefully stop the bot and remove containers, run the following command: 174 | 175 | ```shell 176 | make down 177 | ``` 178 | 179 | ### Caddy 180 | 181 | The project uses Caddy as a web server. Caddy can automatically get and renew SSL certificates. To configure Caddy, edit 182 | the `Caddyfile` file in the `caddy` directory. `public` directory is used to store static files. 183 | 184 | By default, Caddy is disabled in the `docker-compose.yml` file. To enable Caddy, uncomment the `caddy` service in the 185 | `docker-compose.yml` file. 186 | 187 | ### Webhooks 188 | 189 | Bot may use webhooks. To enable webhooks, set `WEBHOOKS` environment variable to `True` in the `.env` file. Also, set 190 | `WEBHOOK_URL` and `WEBHOOK_SECRET_TOKEN` environment variables. 191 | 192 | Don't forget to uncomment the `caddy` service in the `docker-compose.yml` file. 193 | 194 | *** 195 | 196 | ## FAQ 197 | 198 | **Q:** Why PyCharm marks import with red color? 199 | 200 | **A:** I use "unique" project structure, where `app` directory contains code, but root directory contains configuration 201 | files. 202 | 203 | In PyCharm, right-click on the `bot` directory and select `Mark Directory as` -> `Sources Root`. Also, 204 | **unmark** project root directory `Unmark as Sources Root`. This will fix the problem. 205 | 206 | ![image](https://github.com/user-attachments/assets/f4acbd42-f4e7-4e1b-9e16-a40db71ac672) 207 | 208 | ![image](https://github.com/user-attachments/assets/01f4f030-46e0-4267-a5bc-4b05ae0b9015) 209 | 210 | ![image](https://github.com/user-attachments/assets/f2e02548-173b-4be6-944f-623ff7dc2207) 211 | 212 | *** 213 | 214 | **Q:** Why You import `sys` or `os` libs 215 | like [this](https://github.com/andrew000/aiogram-template/blob/6052d9bd2cbb9332620f5996bf6065a0b918d3bf/app/bot/__main__.py#L140)? 216 | 217 | **A:** _My inclinations make me do this to avoid some attack vector invented by my paranoia_ 218 | *** 219 | 220 | **Q:** Why not use `aiogram-cli`? 221 | 222 | **A:** _It's a good library, but I prefer to use my own code 🤷‍♂️_ 223 | *** 224 | 225 | ## Useful commands 226 | 227 | #### Update Dependencies 228 | 229 | First, run `make outdated` to check for outdated dependencies. Then, edit `pyproject.toml` file and run the 230 | following command to update dependencies: 231 | 232 | ```shell 233 | make outdated 234 | 235 | # Edit pyproject.toml 236 | 237 | uv lock --upgrade 238 | make sync 239 | ``` 240 | 241 | #### Check Dependencies Updates 242 | 243 | ```shell 244 | make outdated 245 | ``` 246 | 247 | #### Linting 248 | 249 | ```shell 250 | make lint 251 | ``` 252 | 253 | #### MyPy 254 | 255 | ```shell 256 | make mypy 257 | ``` 258 | 259 | #### Formatting 260 | 261 | ```shell 262 | make format 263 | ``` 264 | -------------------------------------------------------------------------------- /app/bot/.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | **/*.py[cod] 3 | **/__pycache__/ 4 | stub.json 5 | Dockerfile 6 | .dockerignore 7 | -------------------------------------------------------------------------------- /app/bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.6-slim 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PIP_DISABLE_PIP_VERSION_CHECK=1 5 | 6 | ARG USER_ID=${USER_ID:-999} 7 | ARG GROUP_ID=${GROUP_ID:-999} 8 | ARG USER_NAME=${USER_NAME:-bot} 9 | 10 | WORKDIR /app 11 | 12 | RUN if [ "$USER_NAME" != "root" ]; then \ 13 | echo "Creating non-root user: $USER_NAME" && \ 14 | groupadd --system --gid=${GROUP_ID} ${USER_NAME} && \ 15 | useradd --system --shell /bin/false --no-log-init --gid=${GROUP_ID} --uid=${USER_ID} ${USER_NAME} && \ 16 | chown ${USER_NAME}:${USER_NAME} /app ; \ 17 | else \ 18 | echo "Running as root, skipping user creation"; \ 19 | fi 20 | 21 | USER ${USER_NAME} 22 | 23 | COPY --chown=${USER_NAME}:${USER_NAME} pyproject.toml /app/ 24 | 25 | RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \ 26 | uv --no-cache sync --no-dev 27 | 28 | COPY --chown=${USER_NAME}:${USER_NAME} . /app/ 29 | -------------------------------------------------------------------------------- /app/bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/__init__.py -------------------------------------------------------------------------------- /app/bot/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import aiogram_errors 4 | 5 | router = Router() 6 | 7 | router.include_routers(aiogram_errors.router) 8 | -------------------------------------------------------------------------------- /app/bot/errors/aiogram_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router 7 | 8 | if TYPE_CHECKING: 9 | from aiogram.types import ErrorEvent 10 | 11 | router = Router() 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(logging.ERROR) 14 | 15 | file_handler = logging.FileHandler("errors.log", encoding="utf-8") 16 | formatter = logging.Formatter( 17 | "%(levelname)s:%(name)s - %(asctime)s - on line `%(lineno)d`\n%(message)s\n", 18 | ) 19 | file_handler.setFormatter(formatter) 20 | file_handler.setLevel(logging.ERROR) 21 | logger.addHandler(file_handler) 22 | 23 | 24 | @router.errors() 25 | async def errors_handler(event: ErrorEvent) -> None: 26 | logger.error("Update: (%s)\nException: %s\n", event.update, event.exception) 27 | -------------------------------------------------------------------------------- /app/bot/errors/errors.py: -------------------------------------------------------------------------------- 1 | from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError 2 | 3 | 4 | class UserIsAnAdministratorError(TelegramBadRequest): 5 | message = "Bad Request: user is an administrator of the chat" 6 | 7 | 8 | class CantRestrictSelfError(TelegramBadRequest): 9 | message = "Bad Request: can't restrict self" 10 | 11 | 12 | class NotEnoughRightsError(TelegramBadRequest): 13 | message = "Bad Request: not enough rights" 14 | 15 | 16 | class NotEnoughRightsToRestrictError(TelegramBadRequest): 17 | message = "Bad Request: not enough rights to restrict/unrestrict chat member" 18 | 19 | 20 | class TopicClosedError(TelegramBadRequest): 21 | message = "Bad Request: TOPIC_CLOSED" 22 | 23 | 24 | class ChatNotFoundError(TelegramBadRequest): 25 | message = "Bad Request: chat not found" 26 | 27 | 28 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: CHAT_RESTRICTED 29 | class ChatRestrictedError(TelegramBadRequest): 30 | message = "Bad Request: CHAT_RESTRICTED" 31 | 32 | 33 | # aiogram.exceptions.TelegramForbiddenError: Telegram server says - Forbidden: bot was kicked from 34 | # the supergroup chat 35 | class BotWasKickedError(TelegramForbiddenError): 36 | message = "Forbidden: bot was kicked from the supergroup chat" 37 | 38 | 39 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: REACTION_INVALID 40 | class ReactionInvalidError(TelegramBadRequest): 41 | message = "Bad Request: REACTION_INVALID" 42 | 43 | 44 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: not enough rights to 45 | # send text messages to the chat 46 | class NotEnoughRightsToSendTextError(TelegramBadRequest): 47 | message = "Bad Request: not enough rights to send text messages to the chat" 48 | 49 | 50 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: MESSAGE_ID_INVALID 51 | class MessageIdInvalidError(TelegramBadRequest): 52 | message = "Bad Request: MESSAGE_ID_INVALID" 53 | 54 | 55 | # aiogram.exceptions.TelegramForbiddenError: Telegram server says - Forbidden: bot was blocked by 56 | # the user 57 | class BotWasBlockedByUserError(TelegramForbiddenError): 58 | message = "Forbidden: bot was blocked by the user" 59 | 60 | 61 | # aiogram.exceptions.TelegramForbiddenError: Telegram server says - Forbidden: bot was kicked 62 | # from the group chat 63 | class BotWasKickedFromGroupError(TelegramForbiddenError): 64 | message = "Forbidden: bot was kicked from the group chat" 65 | 66 | 67 | # aiogram.exceptions.TelegramForbiddenError: Telegram server says - Forbidden: bot was kicked 68 | # from the channel chat 69 | class BotWasKickedFromChannelError(TelegramForbiddenError): 70 | message = "Forbidden: bot was kicked from the channel chat" 71 | 72 | 73 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: channel direct 74 | # messages topic must be specified 75 | class ChannelDirectMessagesTopicMustBeSpecifiedError(TelegramBadRequest): 76 | message = "Bad Request: channel direct messages topic must be specified" 77 | 78 | 79 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: can't remove chat owner 80 | class CantRemoveChatOwnerError(TelegramBadRequest): 81 | message = "Bad Request: can't remove chat owner" 82 | 83 | 84 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: CHAT_ADMIN_REQUIRED 85 | class ChatAdminRequiredError(TelegramBadRequest): 86 | message = "Bad Request: CHAT_ADMIN_REQUIRED" 87 | 88 | 89 | # aiogram.exceptions.TelegramBadRequest: Telegram server says - Bad Request: message to react not 90 | # found 91 | class MessageToReactNotFoundError(TelegramBadRequest): 92 | message = "Bad Request: message to react not found" 93 | 94 | 95 | def resolve_exception(exception: TelegramAPIError) -> TelegramAPIError: 96 | match exception.message: 97 | case UserIsAnAdministratorError.message as message: 98 | return UserIsAnAdministratorError(method=exception.method, message=message) 99 | 100 | case CantRestrictSelfError.message as message: 101 | return CantRestrictSelfError(method=exception.method, message=message) 102 | 103 | case NotEnoughRightsError.message as message: 104 | return NotEnoughRightsError(method=exception.method, message=message) 105 | 106 | case NotEnoughRightsToRestrictError.message as message: 107 | return NotEnoughRightsToRestrictError(method=exception.method, message=message) 108 | 109 | case TopicClosedError.message as message: 110 | return TopicClosedError(method=exception.method, message=message) 111 | 112 | case ChatNotFoundError.message as message: 113 | return ChatNotFoundError(method=exception.method, message=message) 114 | 115 | case ChatRestrictedError.message as message: 116 | return ChatRestrictedError(method=exception.method, message=message) 117 | 118 | case BotWasBlockedByUserError.message as message: 119 | return BotWasBlockedByUserError(method=exception.method, message=message) 120 | 121 | case BotWasKickedError.message as message: 122 | return BotWasKickedError(method=exception.method, message=message) 123 | 124 | case ReactionInvalidError.message as message: 125 | return ReactionInvalidError(method=exception.method, message=message) 126 | 127 | case NotEnoughRightsToSendTextError.message as message: 128 | return NotEnoughRightsToSendTextError(method=exception.method, message=message) 129 | 130 | case MessageIdInvalidError.message as message: 131 | return MessageIdInvalidError(method=exception.method, message=message) 132 | 133 | case BotWasKickedFromGroupError.message as message: 134 | return BotWasKickedFromGroupError(method=exception.method, message=message) 135 | 136 | case BotWasKickedFromChannelError.message as message: 137 | return BotWasKickedFromChannelError(method=exception.method, message=message) 138 | 139 | case ChannelDirectMessagesTopicMustBeSpecifiedError.message as message: 140 | return ChannelDirectMessagesTopicMustBeSpecifiedError( 141 | method=exception.method, message=message 142 | ) 143 | 144 | case CantRemoveChatOwnerError.message as message: 145 | return CantRemoveChatOwnerError(method=exception.method, message=message) 146 | 147 | case ChatAdminRequiredError.message as message: 148 | return ChatAdminRequiredError(method=exception.method, message=message) 149 | 150 | case MessageToReactNotFoundError.message as message: 151 | return MessageToReactNotFoundError(method=exception.method, message=message) 152 | 153 | case _: 154 | return exception 155 | -------------------------------------------------------------------------------- /app/bot/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/filters/__init__.py -------------------------------------------------------------------------------- /app/bot/filters/cb_click_by_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | from typing import TYPE_CHECKING, Protocol, Self 5 | 6 | import msgspec 7 | from aiogram.filters import Filter 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Sequence 11 | 12 | from aiogram.types import CallbackQuery 13 | from redis.asyncio import Redis 14 | from redis.typing import ExpiryT 15 | 16 | from stub import I18nContext 17 | 18 | 19 | class HasOwnerId(Protocol): 20 | owner_id: int 21 | 22 | 23 | class CallbackClickedByTargetUser[T: HasOwnerId](Filter): 24 | async def __call__( 25 | self, 26 | query: CallbackQuery, 27 | callback_data: T | None = None, 28 | ) -> bool: 29 | if not callback_data or not hasattr(callback_data, "owner_id"): 30 | return False 31 | 32 | if query.from_user.id != callback_data.owner_id: 33 | await query.answer("❌", show_alert=True) 34 | return False 35 | 36 | return True 37 | 38 | 39 | class MsgOwner(msgspec.Struct, kw_only=True, array_like=True): 40 | owner_id: int 41 | 42 | @classmethod 43 | def key(cls, chat_id: int, message_id: int) -> str: 44 | return f"{cls.__name__}:{chat_id}:{message_id}" 45 | 46 | @classmethod 47 | async def get(cls, redis: Redis, chat_id: int, message_id: int) -> Self | None: 48 | data = await redis.get(cls.key(chat_id, message_id)) 49 | if data: 50 | return msgspec.msgpack.decode(data, type=cls) 51 | return None 52 | 53 | @classmethod 54 | async def set( 55 | cls, 56 | redis: Redis, 57 | chat_id: int, 58 | message_id: int, 59 | owner_id: int, 60 | ttl: ExpiryT = timedelta(days=2), 61 | ) -> int: 62 | return await redis.setex( 63 | name=cls.key(chat_id, message_id), 64 | time=ttl, 65 | value=msgspec.msgpack.encode(cls(owner_id=owner_id)), 66 | ) 67 | 68 | @classmethod 69 | async def delete(cls, redis: Redis, chat_id: int, message_id: int) -> None: 70 | await redis.delete(cls.key(chat_id, message_id)) 71 | 72 | 73 | class CallbackClickedByRedisUser(Filter): 74 | async def __call__(self, cb: CallbackQuery, i18n: I18nContext, redis: Redis) -> bool: 75 | message_owner = await MsgOwner.get( 76 | redis=redis, chat_id=cb.message.chat.id, message_id=cb.message.message_id 77 | ) 78 | if not message_owner: 79 | if cb.message.text: 80 | await cb.message.edit_text(i18n.message.deprecated()) 81 | else: 82 | await cb.message.edit_caption(caption=i18n.message.deprecated()) 83 | return False 84 | 85 | if cb.from_user.id != message_owner.owner_id: 86 | await cb.answer("❌", show_alert=True) 87 | return False 88 | 89 | return True 90 | 91 | 92 | class MsgMultipleOwners(msgspec.Struct, kw_only=True, array_like=True): 93 | owner_ids: frozenset[int] 94 | 95 | @classmethod 96 | def key(cls, chat_id: int, message_id: int) -> str: 97 | return f"{cls.__name__}:{chat_id}:{message_id}" 98 | 99 | @classmethod 100 | async def get(cls, redis: Redis, chat_id: int, message_id: int) -> Self | None: 101 | data = await redis.get(cls.key(chat_id, message_id)) 102 | if data: 103 | return msgspec.msgpack.decode(data, type=cls) 104 | return None 105 | 106 | @classmethod 107 | async def set( 108 | cls, 109 | redis: Redis, 110 | chat_id: int, 111 | message_id: int, 112 | owner_ids: Sequence[int], 113 | ttl: ExpiryT = timedelta(days=2), 114 | ) -> int: 115 | return await redis.setex( 116 | name=cls.key(chat_id, message_id), 117 | time=ttl, 118 | value=msgspec.msgpack.encode(cls(owner_ids=frozenset(owner_ids))), 119 | ) 120 | 121 | @classmethod 122 | async def delete(cls, redis: Redis, chat_id: int, message_id: int) -> None: 123 | await redis.delete(cls.key(chat_id, message_id)) 124 | 125 | 126 | class CallbackClickedByMultipleRedisUser(Filter): 127 | async def __call__(self, cb: CallbackQuery, i18n: I18nContext, redis: Redis) -> bool: 128 | message_owners = await MsgMultipleOwners.get( 129 | redis=redis, chat_id=cb.message.chat.id, message_id=cb.message.message_id 130 | ) 131 | if not message_owners: 132 | if cb.message.text: 133 | await cb.message.edit_text(i18n.message.deprecated()) 134 | else: 135 | await cb.message.edit_caption(caption=i18n.message.deprecated()) 136 | return False 137 | 138 | if cb.from_user.id not in message_owners.owner_ids: 139 | await cb.answer("❌", show_alert=True) 140 | return False 141 | 142 | return True 143 | -------------------------------------------------------------------------------- /app/bot/filters/lazy_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from aiogram.filters import Filter 6 | 7 | if TYPE_CHECKING: 8 | from aiogram.types import Message 9 | from aiogram_i18n import I18nContext 10 | 11 | 12 | class LazyFilter(Filter): 13 | """ 14 | I don't like `LazyFilter` provided by `aiogram-i18n`, so I've created my own version of 15 | it. 16 | """ 17 | 18 | def __init__(self, key: str, casefold: bool = True, **__: Any) -> None: 19 | self.key = key # FTL key 20 | self.casefold = casefold 21 | self.values: frozenset[str] = frozenset() # FTL values 22 | self._is_initiated: bool = False 23 | 24 | def startup(self, i18n: I18nContext) -> None: 25 | if self._is_initiated: 26 | return 27 | 28 | self.values = frozenset( 29 | {i18n.core.get(self.key, locale) for locale in i18n.core.available_locales}, 30 | ) 31 | 32 | if self.casefold: 33 | self.values = frozenset({value.casefold() for value in self.values}) 34 | 35 | self._is_initiated = True 36 | 37 | async def __call__(self, event: Message, i18n: I18nContext) -> bool: 38 | self.startup( 39 | i18n, 40 | ) # Temporary solution, because of https://github.com/aiogram/i18n/issues/38 41 | 42 | if not (text := (event.text or event.caption)): 43 | return False 44 | 45 | if self.casefold: 46 | text = text.casefold() 47 | 48 | return text in self.values 49 | 50 | 51 | LF: type[LazyFilter] = LazyFilter 52 | -------------------------------------------------------------------------------- /app/bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import cbs, chat_member, chat_migrate, cmds 4 | 5 | router = Router() 6 | 7 | router.include_routers( 8 | cbs.router, 9 | cmds.router, 10 | chat_member.router, 11 | chat_migrate.router, 12 | ) 13 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import language_settings, start, universal_close 4 | 5 | router = Router() 6 | router.include_routers( 7 | language_settings.router, 8 | start.router, 9 | universal_close.router, 10 | ) 11 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/language_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import router 2 | 3 | __all__ = ("router",) 4 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/language_settings/keyboards.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import TYPE_CHECKING, cast 5 | 6 | from aiogram.filters.callback_data import CallbackData 7 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 8 | from aiogram.utils.keyboard import InlineKeyboardBuilder 9 | 10 | from utils.callback_data_prefix_enums import CallbackDataPrefix 11 | 12 | if TYPE_CHECKING: 13 | from stub import I18nContext 14 | 15 | 16 | class PossibleLanguages(Enum): 17 | en = "en" # English 18 | uk = "uk" # Ukrainian 19 | 20 | 21 | class LanguageWindowCB(CallbackData, prefix=CallbackDataPrefix.language_window): 22 | pass 23 | 24 | 25 | class SelectLanguageCB(CallbackData, prefix=CallbackDataPrefix.select_language): 26 | language: PossibleLanguages 27 | 28 | 29 | def select_language_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 30 | builder = InlineKeyboardBuilder().row( 31 | *[ 32 | InlineKeyboardButton( 33 | text=i18n.settings.select_language.code( 34 | language_code=language.value, 35 | _path="cmds/user_settings.ftl", 36 | ), 37 | callback_data=SelectLanguageCB(language=language).pack(), 38 | ) 39 | for language in PossibleLanguages 40 | ], 41 | width=2, 42 | ) 43 | return cast(InlineKeyboardMarkup, builder.as_markup()) 44 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/language_settings/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 7 | from sqlalchemy import update 8 | from sqlalchemy.sql.operators import eq 9 | 10 | from filters.cb_click_by_user import CallbackClickedByRedisUser, MsgOwner 11 | from handlers.cbs.language_settings.keyboards import ( 12 | LanguageWindowCB, 13 | SelectLanguageCB, 14 | select_language_keyboard, 15 | ) 16 | from handlers.cbs.start import GOTOStartCB 17 | from storages.psql.user import UserSettingsModel 18 | from storages.redis.user import UserSettingsRD 19 | 20 | if TYPE_CHECKING: 21 | from aiogram.types import CallbackQuery 22 | from redis.asyncio import Redis 23 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 24 | 25 | from stub import I18nContext 26 | 27 | router = Router() 28 | 29 | 30 | @router.callback_query(LanguageWindowCB.filter(), CallbackClickedByRedisUser()) 31 | async def language_window_cb( 32 | cb: CallbackQuery, 33 | i18n: I18nContext, 34 | redis: Redis, 35 | ) -> None: 36 | await cb.message.edit_text( 37 | i18n.settings.select_language.text(_path="cmds/user_settings.ftl"), 38 | reply_markup=select_language_keyboard(i18n), 39 | ) 40 | 41 | await MsgOwner.set( 42 | redis=redis, 43 | chat_id=cb.message.chat.id, 44 | message_id=cb.message.message_id, 45 | owner_id=cb.from_user.id, 46 | ) 47 | 48 | 49 | @router.callback_query(SelectLanguageCB.filter(), CallbackClickedByRedisUser()) 50 | async def language_selected_cb( 51 | cb: CallbackQuery, 52 | callback_data: SelectLanguageCB, 53 | i18n: I18nContext, 54 | db_pool: async_sessionmaker[AsyncSession], 55 | redis: Redis, 56 | ) -> None: 57 | async with db_pool() as session: 58 | stmt = ( 59 | update(UserSettingsModel) 60 | .where(eq(UserSettingsModel.id, cb.from_user.id)) 61 | .values(language_code=callback_data.language.value) 62 | ) 63 | await session.execute(stmt) 64 | await session.commit() 65 | 66 | await UserSettingsRD.delete(redis, cb.from_user.id) 67 | 68 | await i18n.set_locale(callback_data.language.value) 69 | 70 | await cb.message.edit_text( 71 | i18n.settings.select_language.changed( 72 | language_code=callback_data.language.value, 73 | _path="cmds/user_settings.ftl", 74 | ), 75 | reply_markup=InlineKeyboardMarkup( 76 | inline_keyboard=[ 77 | [ 78 | InlineKeyboardButton( 79 | text=i18n.settings.select_language.goto_start( 80 | _path="cmds/user_settings.ftl", 81 | ), 82 | callback_data=GOTOStartCB().pack(), 83 | ), 84 | ], 85 | ], 86 | ), 87 | ) 88 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/start.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.filters.callback_data import CallbackData 7 | from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup 8 | 9 | from filters.cb_click_by_user import CallbackClickedByRedisUser, MsgOwner 10 | from handlers.cbs.language_settings.keyboards import LanguageWindowCB 11 | from handlers.cbs.universal_close import UniversalWindowCloseCB 12 | from utils.callback_data_prefix_enums import CallbackDataPrefix 13 | 14 | if TYPE_CHECKING: 15 | from redis.asyncio import Redis 16 | 17 | from stub import I18nContext 18 | 19 | router = Router() 20 | 21 | 22 | class GOTOStartCB(CallbackData, prefix=CallbackDataPrefix.goto_start): 23 | pass 24 | 25 | 26 | @router.callback_query(GOTOStartCB.filter(), CallbackClickedByRedisUser()) 27 | async def start_cb(cb: CallbackQuery, i18n: I18nContext, redis: Redis) -> None: 28 | await cb.message.edit_text( 29 | i18n.start.start_text(user_mention=cb.from_user.mention_html(), _path="cmds/start.ftl"), 30 | reply_markup=InlineKeyboardMarkup( 31 | inline_keyboard=[ 32 | [ 33 | InlineKeyboardButton( 34 | text=i18n.change_language.button(_path="cmds/start.ftl"), 35 | callback_data=LanguageWindowCB().pack(), 36 | ), 37 | ], 38 | [ 39 | InlineKeyboardButton( 40 | text=i18n.close.windows(), 41 | callback_data=UniversalWindowCloseCB().pack(), 42 | ), 43 | ], 44 | ], 45 | ), 46 | disable_web_page_preview=True, 47 | ) 48 | 49 | await MsgOwner.set( 50 | redis=redis, 51 | chat_id=cb.message.chat.id, 52 | message_id=cb.message.message_id, 53 | owner_id=cb.from_user.id, 54 | ) 55 | -------------------------------------------------------------------------------- /app/bot/handlers/cbs/universal_close.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.filters.callback_data import CallbackData 7 | 8 | from filters.cb_click_by_user import CallbackClickedByRedisUser, MsgOwner 9 | from utils.callback_data_prefix_enums import CallbackDataPrefix 10 | 11 | if TYPE_CHECKING: 12 | from aiogram.types import CallbackQuery 13 | from redis.asyncio import Redis 14 | 15 | from stub import I18nContext 16 | 17 | router = Router() 18 | 19 | 20 | class UniversalWindowCloseCB(CallbackData, prefix=CallbackDataPrefix.universal_close): 21 | pass 22 | 23 | 24 | @router.callback_query(UniversalWindowCloseCB.filter(), CallbackClickedByRedisUser()) 25 | async def universal_close_cb(cb: CallbackQuery, i18n: I18nContext, redis: Redis) -> None: 26 | await cb.message.edit_text(i18n.window.closed()) 27 | 28 | await MsgOwner.delete( 29 | redis=redis, 30 | chat_id=cb.message.chat.id, 31 | message_id=cb.message.message_id, 32 | ) 33 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router 2 | from aiogram.enums import ChatType 3 | 4 | from . import ( 5 | any_to_administrator, 6 | any_to_kicked, 7 | any_to_left, 8 | any_to_member, 9 | any_to_restricted, 10 | any_to_unhandled, 11 | my_chat_member, 12 | ) 13 | 14 | router = Router() 15 | 16 | router.include_router(my_chat_member.router) # Must be first 17 | router.include_routers( 18 | any_to_administrator.router, 19 | any_to_kicked.router, 20 | any_to_left.router, 21 | any_to_member.router, 22 | any_to_restricted.router, 23 | ) 24 | router.include_router(any_to_unhandled.router) # Must be last 25 | 26 | router.chat_member.filter(F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP})) 27 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_administrator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.filters import ADMINISTRATOR, CREATOR, PROMOTED_TRANSITION, ChatMemberUpdatedFilter 7 | 8 | from storages.redis.chat_member import RDChatMemberModel 9 | 10 | if TYPE_CHECKING: 11 | from aiogram.types import ChatMemberUpdated 12 | from redis.asyncio.client import Redis 13 | 14 | router = Router() 15 | 16 | 17 | @router.chat_member(ChatMemberUpdatedFilter(PROMOTED_TRANSITION)) 18 | @router.chat_member(ChatMemberUpdatedFilter(ADMINISTRATOR >> ADMINISTRATOR)) 19 | @router.chat_member(ChatMemberUpdatedFilter(CREATOR >> CREATOR)) 20 | async def any_to_administrator(chat_member: ChatMemberUpdated, redis: Redis) -> None: 21 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 22 | await chat_user_model.save(redis) 23 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_kicked.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.filters import KICKED, ChatMemberUpdatedFilter 7 | 8 | from storages.redis.chat_member import RDChatMemberModel 9 | 10 | if TYPE_CHECKING: 11 | from aiogram.types import ChatMemberUpdated 12 | from redis.asyncio.client import Redis 13 | 14 | router = Router() 15 | 16 | 17 | @router.chat_member(ChatMemberUpdatedFilter(KICKED)) 18 | async def any_to_kicked(chat_member: ChatMemberUpdated, redis: Redis) -> None: 19 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 20 | await chat_user_model.save(redis) 21 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_left.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot, Router 6 | from aiogram.exceptions import TelegramBadRequest 7 | from aiogram.filters import KICKED, LEAVE_TRANSITION, LEFT, ChatMemberUpdatedFilter 8 | 9 | from errors.errors import TopicClosedError, resolve_exception 10 | from storages.redis.chat_member import RDChatMemberModel 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import ChatMemberUpdated 14 | from redis.asyncio.client import Redis 15 | 16 | from storages.redis.chat import ChatSettingsModelRD 17 | from stub import I18nContext 18 | 19 | router = Router() 20 | 21 | 22 | @router.chat_member(ChatMemberUpdatedFilter(LEAVE_TRANSITION)) 23 | async def leave_transition( 24 | chat_member: ChatMemberUpdated, 25 | bot: Bot, 26 | i18n: I18nContext, 27 | redis: Redis, 28 | chat_settings: ChatSettingsModelRD, 29 | ) -> None: 30 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 31 | await chat_user_model.save(redis) 32 | 33 | try: 34 | with i18n.use_locale(chat_settings.language_code): 35 | await bot.send_message( 36 | chat_id=chat_member.chat.id, 37 | text=i18n.chat_member.leave_transition( 38 | mention=chat_member.new_chat_member.user.mention_html(), 39 | _path="chat_member/chat_member.ftl", 40 | ), 41 | ) 42 | 43 | except TelegramBadRequest as e: 44 | e = resolve_exception(e) 45 | 46 | match e: 47 | case TopicClosedError(): 48 | return 49 | 50 | case _: 51 | raise 52 | 53 | 54 | @router.chat_member(ChatMemberUpdatedFilter(KICKED >> LEFT)) 55 | async def kicked_to_left(chat_member: ChatMemberUpdated, redis: Redis) -> None: 56 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 57 | await chat_user_model.save(redis) 58 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_member.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot, Router 6 | from aiogram.exceptions import TelegramBadRequest 7 | from aiogram.filters import JOIN_TRANSITION, MEMBER, ChatMemberUpdatedFilter 8 | 9 | from errors.errors import TopicClosedError, resolve_exception 10 | from storages.redis.chat_member import RDChatMemberModel 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import ChatMemberUpdated 14 | from redis.asyncio.client import Redis 15 | 16 | from storages.redis.chat import ChatSettingsModelRD 17 | from stub import I18nContext 18 | 19 | router = Router() 20 | 21 | 22 | @router.chat_member(ChatMemberUpdatedFilter(JOIN_TRANSITION)) 23 | async def left_to_member( 24 | chat_member: ChatMemberUpdated, 25 | bot: Bot, 26 | i18n: I18nContext, 27 | redis: Redis, 28 | chat_settings: ChatSettingsModelRD, 29 | ) -> None: 30 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 31 | await chat_user_model.save(redis) 32 | 33 | try: 34 | with i18n.use_locale(chat_settings.language_code): 35 | await bot.send_message( 36 | chat_id=chat_member.chat.id, 37 | text=i18n.chat_member.join_transition( 38 | mention=chat_member.new_chat_member.user.mention_html(), 39 | _path="chat_member/chat_member.ftl", 40 | ), 41 | ) 42 | 43 | except TelegramBadRequest as e: 44 | e = resolve_exception(e) 45 | 46 | match e: 47 | case TopicClosedError(): 48 | return 49 | 50 | case _: 51 | raise 52 | 53 | 54 | @router.chat_member(ChatMemberUpdatedFilter(MEMBER)) 55 | async def any_to_member(chat_member: ChatMemberUpdated, redis: Redis) -> None: 56 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 57 | await chat_user_model.save(redis) 58 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_restricted.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router 7 | from aiogram.filters import RESTRICTED, ChatMemberUpdatedFilter 8 | 9 | from storages.redis.chat_member import RDChatMemberModel 10 | 11 | if TYPE_CHECKING: 12 | from aiogram.types import ChatMemberUpdated 13 | from redis.asyncio.client import Redis 14 | 15 | logger = logging.getLogger(__name__) 16 | router = Router() 17 | 18 | 19 | @router.chat_member(ChatMemberUpdatedFilter(RESTRICTED)) 20 | async def any_to_restricted(chat_member: ChatMemberUpdated, redis: Redis) -> None: 21 | chat_user_model = RDChatMemberModel.resolve(chat_member.chat.id, chat_member.new_chat_member) 22 | await chat_user_model.save(redis) 23 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/any_to_unhandled.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import uuid 5 | from typing import TYPE_CHECKING 6 | 7 | from aiogram import Bot, Router 8 | 9 | if TYPE_CHECKING: 10 | from aiogram.types import ChatMemberUpdated 11 | 12 | logger = logging.getLogger(__name__) 13 | router = Router() 14 | 15 | 16 | @router.chat_member() 17 | async def any_to_unhandled(chat_member: ChatMemberUpdated, bot: Bot, developer_id: int) -> None: 18 | alert_id = uuid.uuid4() 19 | 20 | logger.warning( 21 | "🚨 DETECTED ANY TO UNHANDLED\n" 22 | "%s -> %s.\n" 23 | "Alert id: %s\n" 24 | "User id: %s\n" 25 | "Mention: %s\n" 26 | "Chat id: %s\n", 27 | chat_member.old_chat_member, 28 | chat_member.new_chat_member, 29 | alert_id, 30 | chat_member.new_chat_member.user.id, 31 | chat_member.new_chat_member.user.mention_html(), 32 | chat_member.chat.id, 33 | ) 34 | 35 | await bot.send_message( 36 | developer_id, 37 | f"🚨 DETECTED ANY TO UNHANDLED:\n" 38 | f"Alert id: {alert_id}\n" 39 | f"user_id: {chat_member.new_chat_member.user.id}\n" 40 | f"mention: {chat_member.new_chat_member.user.mention_html()}\n" 41 | "Details in /logs", 42 | ) 43 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/my_chat_member/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import groups, private 4 | 5 | router = Router() 6 | router.include_routers(private.router, groups.router) 7 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/my_chat_member/groups.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from datetime import timedelta 5 | from secrets import randbelow 6 | from typing import TYPE_CHECKING 7 | 8 | from aiogram import Bot, F, Router 9 | from aiogram.enums import ChatType 10 | from aiogram.filters import ( 11 | ADMINISTRATOR, 12 | IS_NOT_MEMBER, 13 | LEAVE_TRANSITION, 14 | MEMBER, 15 | PROMOTED_TRANSITION, 16 | RESTRICTED, 17 | ChatMemberUpdatedFilter, 18 | ) 19 | 20 | from storages.redis.chat_member import RDChatBotModel 21 | 22 | if TYPE_CHECKING: 23 | from aiogram.types import ChatMemberUpdated 24 | from redis.asyncio.client import Redis 25 | 26 | from stub import I18nContext 27 | 28 | logger = logging.getLogger(__name__) 29 | router = Router() 30 | 31 | 32 | @router.my_chat_member( 33 | ChatMemberUpdatedFilter(PROMOTED_TRANSITION), 34 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 35 | ) 36 | async def my_chat_member_promoted_transition( 37 | chat_member: ChatMemberUpdated, 38 | bot: Bot, 39 | i18n: I18nContext, 40 | redis: Redis, 41 | ) -> None: 42 | bot_model = RDChatBotModel.resolve( 43 | chat_id=chat_member.chat.id, 44 | chat_member=chat_member.new_chat_member, 45 | ) 46 | 47 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 48 | 49 | logger.info("Bot was promoted in chat %s", chat_member.chat.id) 50 | 51 | await bot.send_message( 52 | chat_id=chat_member.chat.id, 53 | text=i18n.my_chat_member.promoted_transition( 54 | can_delete_messages=bot_model.can_delete_messages, 55 | can_restrict_members=bot_model.can_restrict_members, 56 | can_invite_users=bot_model.can_invite_users, 57 | _path="chat_member/my_chat_member.ftl", 58 | ), 59 | ) 60 | 61 | 62 | @router.my_chat_member( 63 | ChatMemberUpdatedFilter(ADMINISTRATOR >> ADMINISTRATOR), 64 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 65 | ) 66 | async def my_chat_member_administrator_transition( 67 | chat_member: ChatMemberUpdated, 68 | redis: Redis, 69 | ) -> None: 70 | bot_model = RDChatBotModel.resolve( 71 | chat_id=chat_member.chat.id, 72 | chat_member=chat_member.new_chat_member, 73 | ) 74 | 75 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 76 | 77 | logger.info("Bot admin rights was changed in chat %s", chat_member.chat.id) 78 | 79 | 80 | @router.my_chat_member( 81 | ChatMemberUpdatedFilter(IS_NOT_MEMBER >> (MEMBER | +RESTRICTED)), 82 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 83 | ) 84 | async def my_chat_member_join_transition( 85 | chat_member: ChatMemberUpdated, 86 | bot: Bot, 87 | i18n: I18nContext, 88 | redis: Redis, 89 | ) -> None: 90 | bot_model = RDChatBotModel.resolve( 91 | chat_id=chat_member.chat.id, 92 | chat_member=chat_member.new_chat_member, 93 | ) 94 | 95 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 96 | 97 | logger.info("Bot was added to chat %s", chat_member.chat.id) 98 | 99 | await bot.send_message( 100 | chat_id=chat_member.chat.id, 101 | text=i18n.my_chat_member.join_transition( 102 | can_delete_messages="✅" if bot_model.can_delete_messages else "❌", 103 | can_restrict_members="✅" if bot_model.can_restrict_members else "❌", 104 | can_invite_users="✅" if bot_model.can_invite_users else "❌", 105 | _path="chat_member/my_chat_member.ftl", 106 | ), 107 | ) 108 | 109 | 110 | @router.my_chat_member( 111 | ChatMemberUpdatedFilter(+RESTRICTED >> (MEMBER | +RESTRICTED)), 112 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 113 | ) 114 | async def my_chat_member_unrestricted_transition( 115 | chat_member: ChatMemberUpdated, 116 | redis: Redis, 117 | ) -> None: 118 | bot_model = RDChatBotModel.resolve( 119 | chat_id=chat_member.chat.id, 120 | chat_member=chat_member.new_chat_member, 121 | ) 122 | 123 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 124 | 125 | logger.info("Bot was un/restricted in chat %s", chat_member.chat.id) 126 | 127 | 128 | @router.my_chat_member( 129 | ChatMemberUpdatedFilter(ADMINISTRATOR >> (MEMBER | +RESTRICTED)), 130 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 131 | ) 132 | async def my_chat_member_demoted_transition( 133 | chat_member: ChatMemberUpdated, 134 | i18n: I18nContext, 135 | bot: Bot, 136 | redis: Redis, 137 | ) -> None: 138 | bot_model = RDChatBotModel.resolve( 139 | chat_id=chat_member.chat.id, 140 | chat_member=chat_member.new_chat_member, 141 | ) 142 | 143 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 144 | 145 | logger.info("Bot was demoted in chat %s", chat_member.chat.id) 146 | 147 | await bot.send_message( 148 | chat_id=chat_member.chat.id, 149 | text=i18n.my_chat_member.demoted_transition(_path="chat_member/my_chat_member.ftl"), 150 | ) 151 | 152 | 153 | @router.my_chat_member( 154 | ChatMemberUpdatedFilter(LEAVE_TRANSITION), 155 | F.chat.type.in_({ChatType.GROUP, ChatType.SUPERGROUP}), 156 | ) 157 | async def my_chat_member_leave_transition(chat_member: ChatMemberUpdated, redis: Redis) -> None: 158 | bot_model = RDChatBotModel.resolve( 159 | chat_id=chat_member.chat.id, 160 | chat_member=chat_member.new_chat_member, 161 | ) 162 | await bot_model.save(redis, timedelta(minutes=45 + randbelow(75 - 45 + 1))) 163 | 164 | logger.info("Bot was kicked from chat %s", chat_member.chat.id) 165 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_member/my_chat_member/private.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import F, Router 7 | from aiogram.enums import ChatType 8 | from aiogram.filters import KICKED, MEMBER, ChatMemberUpdatedFilter 9 | from sqlalchemy import update 10 | from sqlalchemy.sql.operators import eq 11 | 12 | from storages.psql.user import UserModel 13 | from storages.redis.user import UserRD 14 | 15 | if TYPE_CHECKING: 16 | from aiogram.types import ChatMemberUpdated 17 | from redis.asyncio import Redis 18 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 19 | 20 | router = Router() 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | @router.my_chat_member(ChatMemberUpdatedFilter(KICKED >> MEMBER), F.chat.type == ChatType.PRIVATE) 25 | async def my_chat_member_private_member( 26 | chat_member: ChatMemberUpdated, 27 | db_pool: async_sessionmaker[AsyncSession], 28 | redis: Redis, 29 | ) -> None: 30 | async with db_pool() as session: 31 | stmt = ( 32 | update(UserModel) 33 | .where(eq(UserModel.id, chat_member.from_user.id)) 34 | .values(pm_active=True) 35 | .returning(UserModel) 36 | ) 37 | user_model: UserModel = await session.scalar(stmt) 38 | await session.commit() 39 | 40 | user_model: UserRD = UserRD.from_orm(user_model) 41 | await user_model.save(redis) 42 | 43 | logger.info("Bot was whitelisted by user %s", chat_member.from_user.id) 44 | 45 | 46 | @router.my_chat_member(ChatMemberUpdatedFilter(MEMBER >> KICKED), F.chat.type == ChatType.PRIVATE) 47 | async def my_chat_member_private_kicked( 48 | chat_member: ChatMemberUpdated, 49 | db_pool: async_sessionmaker[AsyncSession], 50 | redis: Redis, 51 | ) -> None: 52 | async with db_pool() as session: 53 | stmt = ( 54 | update(UserModel) 55 | .where(eq(UserModel.id, chat_member.from_user.id)) 56 | .values(pm_active=False) 57 | .returning(UserModel) 58 | ) 59 | user_model: UserModel = await session.scalar(stmt) 60 | await session.commit() 61 | 62 | user_model: UserRD = UserRD.from_orm(user_model) 63 | await user_model.save(redis) 64 | 65 | logger.info("Bot was blacklisted by user %s", chat_member.from_user.id) 66 | -------------------------------------------------------------------------------- /app/bot/handlers/chat_migrate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import UTC, datetime 4 | from typing import TYPE_CHECKING, cast 5 | 6 | from aiogram import F, Router 7 | 8 | from storages.psql import ChatModel 9 | from storages.redis.chat import ChatModelRD, ChatSettingsModelRD 10 | 11 | if TYPE_CHECKING: 12 | from aiogram.types import Message 13 | from redis.asyncio.client import Redis 14 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 15 | 16 | router = Router() 17 | 18 | 19 | @router.message(F.migrate_from_chat_id) 20 | async def chat_migrate( 21 | msg: Message, 22 | db_pool: async_sessionmaker[AsyncSession], 23 | redis: Redis, 24 | ) -> None: 25 | async with db_pool() as session: 26 | async with session.begin(): 27 | chat_model = await session.get(ChatModel, msg.migrate_from_chat_id) 28 | 29 | chat_model.id = msg.chat.id 30 | chat_model.migrate_from_chat_id = msg.migrate_from_chat_id 31 | chat_model.migrate_datetime = datetime.now(tz=UTC) 32 | 33 | await session.commit() 34 | 35 | await ChatModelRD.delete(redis, cast(int, msg.migrate_from_chat_id)) 36 | await ChatSettingsModelRD.delete(redis, cast(int, msg.migrate_from_chat_id)) 37 | -------------------------------------------------------------------------------- /app/bot/handlers/cmds/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | 3 | from . import language_settings, start 4 | 5 | router = Router() 6 | router.include_routers( 7 | language_settings.router, 8 | start.router, 9 | ) 10 | -------------------------------------------------------------------------------- /app/bot/handlers/cmds/language_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router 6 | from aiogram.filters import Command, or_f 7 | 8 | from filters.cb_click_by_user import MsgOwner 9 | from filters.lazy_filter import LF 10 | from handlers.cbs.language_settings.keyboards import select_language_keyboard 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import Message 14 | from redis.asyncio import Redis 15 | 16 | from stub import I18nContext 17 | 18 | router = Router() 19 | 20 | 21 | @router.message( 22 | or_f( 23 | Command("language", "lang"), 24 | LF("settings-lang", _path="cmds/user_settings.ftl"), 25 | LF("settings-language", _path="cmds/user_settings.ftl"), 26 | ), 27 | ) 28 | async def language_cmd(msg: Message, i18n: I18nContext, redis: Redis) -> None: 29 | sent = await msg.answer( 30 | i18n.settings.select_language.text(_path="cmds/user_settings.ftl"), 31 | reply_markup=select_language_keyboard(i18n), 32 | ) 33 | 34 | await MsgOwner.set( 35 | redis=redis, 36 | chat_id=msg.chat.id, 37 | message_id=sent.message_id, 38 | owner_id=msg.from_user.id, 39 | ) 40 | -------------------------------------------------------------------------------- /app/bot/handlers/cmds/start.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router 7 | from aiogram.filters import CommandObject, CommandStart 8 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message 9 | 10 | from filters.cb_click_by_user import MsgOwner 11 | from handlers.cbs.language_settings.keyboards import LanguageWindowCB 12 | from handlers.cbs.universal_close import UniversalWindowCloseCB 13 | 14 | if TYPE_CHECKING: 15 | from redis.asyncio import Redis 16 | 17 | from stub import I18nContext 18 | 19 | router = Router() 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | @router.message(CommandStart(deep_link=True)) 24 | async def start_cmd_with_deep_link( 25 | msg: Message, 26 | command: CommandObject, 27 | i18n: I18nContext, 28 | redis: Redis, 29 | ) -> None: 30 | args = command.args.split() if command.args else [] 31 | deep_link = args[0] 32 | 33 | logger.info("User %s started bot with deeplink: %s", msg.from_user.id, deep_link) 34 | 35 | await start_cmd(msg, i18n, redis) 36 | 37 | 38 | @router.message(CommandStart(deep_link=False)) # Deeplink in False will not work as expected 39 | async def start_cmd(msg: Message, i18n: I18nContext, redis: Redis) -> None: 40 | sent = await msg.answer( 41 | i18n.start.start_text(user_mention=msg.from_user.mention_html(), _path="cmds/start.ftl"), 42 | reply_markup=InlineKeyboardMarkup( 43 | inline_keyboard=[ 44 | [ 45 | InlineKeyboardButton( 46 | text=i18n.change_language.button(_path="cmds/start.ftl"), 47 | callback_data=LanguageWindowCB().pack(), 48 | ), 49 | ], 50 | [ 51 | InlineKeyboardButton( 52 | text=i18n.close.windows(), 53 | callback_data=UniversalWindowCloseCB().pack(), 54 | ), 55 | ], 56 | ], 57 | ), 58 | disable_web_page_preview=True, 59 | ) 60 | 61 | await MsgOwner.set( 62 | redis=redis, 63 | chat_id=msg.chat.id, 64 | message_id=sent.message_id, 65 | owner_id=msg.from_user.id, 66 | ) 67 | -------------------------------------------------------------------------------- /app/bot/locales/en/_default.ftl: -------------------------------------------------------------------------------- 1 | message-deprecated = 🗑 Message deprecated. 2 | close-windows = ❌ Close 3 | window-closed = ✅ Closed 4 | -------------------------------------------------------------------------------- /app/bot/locales/en/chat_member/chat_member.ftl: -------------------------------------------------------------------------------- 1 | chat_member-leave_transition = Bye, { $mention } 2 | chat_member-join_transition = Welcome, { $mention } 3 | -------------------------------------------------------------------------------- /app/bot/locales/en/chat_member/my_chat_member.ftl: -------------------------------------------------------------------------------- 1 | my_chat_member-promoted_transition = 2 | ❤️ Thank you for promoting me to an administrator! 3 | 4 | 💬 For full functionality, I need the following rights: 5 | { $can_delete_messages -> 6 | [1] ✅ Delete messages 7 | *[other] ❌ Delete messages 8 | } 9 | { $can_restrict_members -> 10 | [1] ✅ Restrict members 11 | *[other] ❌ Restrict members 12 | } 13 | { $can_invite_users -> 14 | [1] ✅ Invite users 15 | *[other] ❌ Invite users 16 | } 17 | my_chat_member-join_transition = 18 | ❤️ Thank you for adding me to the chat. 19 | 20 | 💬 For full functionality, I need the following rights: 21 | { $can_delete_messages -> 22 | [1] ✅ Delete messages 23 | *[other] ❌ Delete messages 24 | } 25 | { $can_restrict_members -> 26 | [1] ✅ Restrict members 27 | *[other] ❌ Restrict members 28 | } 29 | { $can_invite_users -> 30 | [1] ✅ Invite users 31 | *[other] ❌ Invite users 32 | } 33 | my_chat_member-demoted_transition = 34 | My administrator rights have been revoked. 35 | 36 | I will continue to work in the chat, but with limited capabilities. 37 | -------------------------------------------------------------------------------- /app/bot/locales/en/cmds/start.ftl: -------------------------------------------------------------------------------- 1 | change_language-button = 🌐 Change language 2 | start-start_text = 3 | 👋 Hi, { $user_mention } 4 | 5 | 🤖 This bot demonstrates a Aiogram template. 6 | 7 | 💁‍♂️ Template created for developing bots in the Python programming language using the Aiogram library. 8 | 9 | 🔗 Template: https://github.com/andrew000/aiogram-template 10 | 😏 Star ⭐️ my repository! 11 | 12 | 💁‍♂️ This template uses the following technologies: 13 |
14 | Libraries: 15 | ─ aiogram (library for working with the Telegram Bot API) 16 | ─ aiogram_i18n (localization library) 17 | ─ FTL-Extract (extracting tool for FTL keys from code) 18 | ─ SQLAlchemy (ORM for working with the database) 19 | ─ Alembic (database migration) 20 | 21 | Databases: 22 | ─ PostgreSQL (database) 23 | ─ Redis (cache and message broker) 24 | 25 | Containerization: 26 | ─ Docker (containerization) 27 | ─ Docker Compose (container orchestration) 28 | 29 | Other technologies: 30 | ─ Caddy (web server) 31 | ─ uv (package and project manager)
32 | -------------------------------------------------------------------------------- /app/bot/locales/en/cmds/user_settings.ftl: -------------------------------------------------------------------------------- 1 | settings-language = language 2 | settings-lang = lang 3 | settings-select_language-code = 4 | { $language_code -> 5 | [uk] Українська 🇺🇦 6 | [en] English 🇺🇸 7 | *[unknown] 🤷‍♂️ 8 | } 9 | settings-select_language-text = 💁‍♂️ Choose a language: 10 | settings-select_language-changed = 11 | ✅ Language changed to: { $language_code -> 12 | [uk] Українська 🇺🇦 13 | [en] English 🇺🇸 14 | *[unknown] 🤷‍♂️ 15 | } 16 | settings-select_language-goto_start = ↪️ Go to start 17 | -------------------------------------------------------------------------------- /app/bot/locales/uk/_default.ftl: -------------------------------------------------------------------------------- 1 | message-deprecated = 🗑 Повідомлення застаріло. 2 | close-windows = ❌ Закрити 3 | window-closed = ✅ Закрито 4 | -------------------------------------------------------------------------------- /app/bot/locales/uk/chat_member/chat_member.ftl: -------------------------------------------------------------------------------- 1 | chat_member-leave_transition = Прощавай, { $mention } 2 | chat_member-join_transition = Ласкаво просимо, { $mention } 3 | -------------------------------------------------------------------------------- /app/bot/locales/uk/chat_member/my_chat_member.ftl: -------------------------------------------------------------------------------- 1 | my_chat_member-promoted_transition = 2 | ❤️ Дякую, що підвищили мене до адміністратора! 3 | 4 | 💬 Для повноцінної роботи мені потрібні такі права: 5 | { $can_delete_messages -> 6 | [1] ✅ Видаляти повідомлення 7 | *[other] ❌ Видаляти повідомлення 8 | } 9 | { $can_restrict_members -> 10 | [1] ✅ Обмежувати користувачів 11 | *[other] ❌ Обмежувати користувачів 12 | } 13 | { $can_invite_users -> 14 | [1] ✅ Запрошувати користувачів 15 | *[other] ❌ Запрошувати користувачів 16 | } 17 | my_chat_member-join_transition = 18 | ❤️ Дякую, що додати мене до чату. 19 | 20 | 💬 Для повноцінної роботи мені потрібні такі права: 21 | { $can_delete_messages -> 22 | [1] ✅ Видаляти повідомлення 23 | *[other] ❌ Видаляти повідомлення 24 | } 25 | { $can_restrict_members -> 26 | [1] ✅ Обмежувати користувачів 27 | *[other] ❌ Обмежувати користувачів 28 | } 29 | { $can_invite_users -> 30 | [1] ✅ Запрошувати користувачів 31 | *[other] ❌ Запрошувати користувачів 32 | } 33 | my_chat_member-demoted_transition = 34 | Мої права адміністратора були забрані. 35 | 36 | Я продовжу працювати в чаті, але з обмеженими можливостями. 37 | -------------------------------------------------------------------------------- /app/bot/locales/uk/cmds/start.ftl: -------------------------------------------------------------------------------- 1 | change_language-button = 🌐 Змінити мову 2 | start-start_text = 3 | 👋 Привіт, { $user_mention } 4 | 5 | 🤖 Це бот для демонстрації шаблону на Aiogram. 6 | 7 | 💁‍♂️ Шаблон створено для розробки ботів на мові програмування Python з використанням бібліотеки Aiogram. 8 | 9 | 🔗 Шаблон: https://github.com/andrew000/aiogram-template 10 | 😏 Буду радий ⭐️ на репозиторій! 11 | 12 | 💁‍♂️ У цьому шаблоні використані такі технології: 13 |
14 | Бібліотеки: 15 | ─ aiogram (бібліотека для роботи з Telegram Bot API) 16 | ─ aiogram_i18n (бібліотека для локалізації) 17 | ─ FTL-Extract (інструмент для видобування FTL ключів з коду) 18 | ─ SQLAlchemy (ORM для роботи з базою даних) 19 | ─ Alembic (міграції бази даних) 20 | 21 | Бази даних: 22 | ─ PostgreSQL (база даних) 23 | ─ Redis (кеш та брокер повідомлень) 24 | 25 | Контейнеризація: 26 | ─ Docker (контейнеризація) 27 | ─ Docker Compose (оркестрація контейнерів) 28 | 29 | Інші технології: 30 | ─ Caddy (веб-сервер) 31 | ─ uv (пакетний і проектний менеджер)
32 | -------------------------------------------------------------------------------- /app/bot/locales/uk/cmds/user_settings.ftl: -------------------------------------------------------------------------------- 1 | settings-language = мова 2 | settings-lang = мова 3 | settings-select_language-code = 4 | { $language_code -> 5 | [uk] Українська 🇺🇦 6 | [en] English 🇺🇸 7 | *[unknown] 🤷‍♂️ 8 | } 9 | settings-select_language-text = 💁‍♂️ Оберіть мову: 10 | settings-select_language-changed = 11 | ✅ Мову змінено на: { $language_code -> 12 | [uk] Українська 🇺🇦 13 | [en] English 🇺🇸 14 | *[unknown] 🤷‍♂️ 15 | } 16 | settings-select_language-goto_start = ↪️ На головну 17 | -------------------------------------------------------------------------------- /app/bot/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from functools import partial 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING, cast 8 | 9 | import msgspec 10 | from aiogram import Bot, Dispatcher 11 | from aiogram.client.default import DefaultBotProperties 12 | from aiogram.client.session.aiohttp import AiohttpSession 13 | from aiogram.client.telegram import PRODUCTION, TEST 14 | from aiogram.fsm.storage.base import DefaultKeyBuilder 15 | from aiogram.fsm.storage.memory import SimpleEventIsolation 16 | from aiogram.fsm.storage.redis import RedisStorage 17 | from aiogram.webhook.aiohttp_server import ( 18 | SimpleRequestHandler, 19 | ip_filter_middleware, 20 | setup_application, 21 | ) 22 | from aiogram.webhook.security import DEFAULT_TELEGRAM_NETWORKS, IPFilter 23 | from aiogram_i18n import I18nMiddleware 24 | from aiogram_i18n.cores import FluentRuntimeCore 25 | from aiohttp import web 26 | from aiohttp.typedefs import Middleware 27 | 28 | import errors 29 | import handlers 30 | from middlewares.check_chat_middleware import CheckChatMiddleware 31 | from middlewares.check_user_middleware import CheckUserMiddleware 32 | from middlewares.throttling_middleware import ThrottlingMiddleware 33 | from settings import Settings 34 | from storages.psql.base import close_db_pool, create_db_pool 35 | from utils.fsm_manager import FSMManager 36 | 37 | if TYPE_CHECKING: 38 | from redis.asyncio import Redis 39 | 40 | logging.basicConfig(level=logging.INFO) 41 | 42 | logger = logging.getLogger(__name__) 43 | logger.setLevel(logging.INFO) 44 | 45 | 46 | async def startup(dispatcher: Dispatcher, bot: Bot, settings: Settings, redis: Redis) -> None: 47 | await bot.delete_webhook(drop_pending_updates=True) 48 | 49 | if not settings.dev: 50 | await bot.set_webhook( 51 | url=settings.webhook_url.get_secret_value(), 52 | allowed_updates=dispatcher.resolve_used_update_types(), 53 | secret_token=settings.webhook_secret_token.get_secret_value(), 54 | ) 55 | 56 | engine, db_pool = await create_db_pool(settings) 57 | 58 | dispatcher.workflow_data.update( 59 | {"db_pool": db_pool, "db_pool_closer": partial(close_db_pool, engine)}, 60 | ) 61 | 62 | dispatcher.message.middleware(ThrottlingMiddleware(redis)) 63 | dispatcher.callback_query.middleware(ThrottlingMiddleware(redis)) 64 | 65 | dispatcher.update.outer_middleware(CheckChatMiddleware()) 66 | dispatcher.update.outer_middleware(CheckUserMiddleware()) 67 | 68 | i18n_middleware = I18nMiddleware( 69 | core=FluentRuntimeCore(path=Path(__file__).parent / "locales" / "{locale}"), 70 | manager=FSMManager(), 71 | ) 72 | i18n_middleware.setup(dispatcher=dispatcher) 73 | await i18n_middleware.core.startup() 74 | 75 | logger.info("Bot started") 76 | 77 | 78 | async def shutdown(dispatcher: Dispatcher) -> None: 79 | await dispatcher["db_pool_closer"]() 80 | logger.info("Bot stopped") 81 | 82 | 83 | async def main() -> None: 84 | settings = Settings() 85 | 86 | # TelegramLocalBotAPIServer 87 | # api = TelegramAPIServer.from_base("http://telegram-bot-api:8081") 88 | # api = TelegramAPIServer.from_base("http://localhost:8081") 89 | api = TEST if settings.test_server is True else PRODUCTION 90 | 91 | bot = Bot( 92 | token=settings.bot_token.get_secret_value(), 93 | session=AiohttpSession(api=api), 94 | default=DefaultBotProperties(parse_mode="HTML"), 95 | ) 96 | 97 | storage = RedisStorage( 98 | redis=await settings.redis_dsn(), 99 | key_builder=DefaultKeyBuilder(with_bot_id=True, with_destiny=True), 100 | json_loads=msgspec.json.decode, 101 | json_dumps=partial(lambda obj: str(msgspec.json.encode(obj), encoding="utf-8")), 102 | ) 103 | 104 | dp = Dispatcher( 105 | storage=storage, 106 | events_isolation=SimpleEventIsolation(), 107 | settings=settings, 108 | redis=storage.redis, 109 | developer_id=settings.developer_id, 110 | ) 111 | dp.include_routers(handlers.router, errors.router) 112 | dp.startup.register(startup) 113 | dp.shutdown.register(shutdown) 114 | 115 | if settings.webhooks: 116 | app = web.Application( 117 | middlewares=[ 118 | cast(Middleware, ip_filter_middleware(IPFilter(DEFAULT_TELEGRAM_NETWORKS))), 119 | ], 120 | ) 121 | 122 | SimpleRequestHandler( 123 | dispatcher=dp, 124 | bot=bot, 125 | secret_token=settings.webhook_secret_token.get_secret_value(), 126 | ).register(app, "/webhook") 127 | setup_application(app, dp, bot=bot) 128 | 129 | await web._run_app(app, host="0.0.0.0", port=8080) # noqa: S104, SLF001 130 | 131 | else: 132 | await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) 133 | 134 | 135 | if __name__ == "__main__": 136 | # Aiogram automatically sets the event loop policy to uvloop if available. 137 | # I really don't like it, because it should be set by the developer. 138 | # Developer decides which event loop to use. 139 | asyncio.run(main()) 140 | -------------------------------------------------------------------------------- /app/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/middlewares/__init__.py -------------------------------------------------------------------------------- /app/bot/middlewares/check_chat_middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, cast 4 | 5 | from aiogram import BaseMiddleware 6 | from aiogram.enums import ChatType 7 | from sqlalchemy import select, update 8 | from sqlalchemy.dialects.postgresql import insert 9 | from sqlalchemy.sql.operators import eq, ne 10 | 11 | from storages.psql.chat import ChatModel, ChatSettingsModel 12 | from storages.redis.chat import ChatModelRD, ChatSettingsModelRD 13 | 14 | if TYPE_CHECKING: 15 | from collections.abc import Awaitable, Callable 16 | 17 | from aiogram.types import Chat, TelegramObject, Update 18 | from redis.asyncio.client import Redis 19 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 20 | 21 | ALLOWED_CHAT_TYPES: frozenset[ChatType] = frozenset( 22 | (ChatType.GROUP, ChatType.SUPERGROUP), 23 | ) 24 | 25 | 26 | async def _create_chat(chat: Chat, session: AsyncSession) -> ChatModel: 27 | if chat.username: 28 | stmt = select(ChatModel).where( 29 | eq(ChatModel.username, chat.username), ne(ChatModel.id, chat.id) 30 | ) 31 | another_chat: ChatModel | None = await session.scalar(stmt) 32 | 33 | if another_chat: 34 | stmt = update(ChatModel).where(eq(ChatModel.id, another_chat.id)).values(username=None) 35 | await session.execute(stmt) 36 | 37 | stmt = select(ChatModel).where(eq(ChatModel.id, chat.id)) 38 | chat_model: ChatModel | None = await session.scalar(stmt) 39 | 40 | if not chat_model: 41 | stmt = ( 42 | insert(ChatModel) 43 | .values( 44 | id=chat.id, 45 | chat_type=ChatType(chat.type), 46 | title=chat.title, 47 | username=chat.username, 48 | member_count=(member_count := await chat.get_member_count()), 49 | ) 50 | .on_conflict_do_update( 51 | index_elements=["id"], 52 | set_={ 53 | "chat_type": ChatType(chat.type), 54 | "title": chat.title, 55 | "username": chat.username, 56 | "member_count": member_count, 57 | }, 58 | ) 59 | .returning(ChatModel) 60 | ) 61 | chat_model = await session.scalar(stmt) 62 | 63 | else: 64 | chat_model.title = chat.title 65 | chat_model.username = chat.username 66 | chat_model.member_count = await chat.get_member_count() 67 | 68 | return cast(ChatModel, chat_model) 69 | 70 | 71 | async def _create_chat_settings( 72 | chat_id: int, 73 | session: AsyncSession, 74 | ) -> ChatSettingsModel: 75 | stmt = select(ChatSettingsModel).where(eq(ChatSettingsModel.id, chat_id)) 76 | chat_settings_model: ChatSettingsModel | None = await session.scalar(stmt) 77 | 78 | if not chat_settings_model: 79 | stmt = ( 80 | insert(ChatSettingsModel) 81 | .values(id=chat_id, language_code="en") 82 | .returning(ChatSettingsModel) 83 | ) 84 | chat_settings_model = await session.scalar(stmt) 85 | 86 | return cast(ChatSettingsModel, chat_settings_model) 87 | 88 | 89 | async def _get_chat_model( 90 | db_pool: async_sessionmaker[AsyncSession], 91 | redis: Redis, 92 | chat: Chat, 93 | ) -> tuple[ChatModelRD, ChatSettingsModelRD]: 94 | chat_model: ChatModelRD | None = await ChatModelRD.get(redis, chat.id) 95 | chat_settings: ChatSettingsModelRD | None = await ChatSettingsModelRD.get(redis, chat.id) 96 | 97 | if chat_model and chat_settings: 98 | return chat_model, chat_settings 99 | 100 | async with db_pool() as session: 101 | async with session.begin(): 102 | chat_model: ChatModel = await _create_chat(chat=chat, session=session) 103 | chat_settings: ChatSettingsModel = await _create_chat_settings( 104 | chat_id=chat.id, session=session 105 | ) 106 | 107 | await session.commit() 108 | 109 | chat_model = ChatModelRD.from_orm(cast(ChatModel, chat_model)) 110 | chat_settings = ChatSettingsModelRD.from_orm(cast(ChatSettingsModel, chat_settings)) 111 | 112 | await chat_model.save(redis) 113 | await chat_settings.save(redis) 114 | 115 | return chat_model, chat_settings 116 | 117 | 118 | class CheckChatMiddleware(BaseMiddleware): 119 | async def __call__( 120 | self, 121 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 122 | event: TelegramObject, 123 | data: dict[str, Any], 124 | ) -> Any: 125 | chat: Chat = data["event_chat"] 126 | 127 | if TYPE_CHECKING: 128 | assert isinstance(event, Update) 129 | 130 | match event.event_type: 131 | case "message": 132 | if chat.type in ALLOWED_CHAT_TYPES: 133 | if ( 134 | event.message.migrate_to_chat_id 135 | or event.message.group_chat_created 136 | or event.message.supergroup_chat_created 137 | ): 138 | return None 139 | 140 | if event.message.migrate_from_chat_id: 141 | return await handler(event, data) 142 | 143 | data["chat_model"], data["chat_settings"] = await _get_chat_model( 144 | db_pool=data["db_pool"], 145 | redis=data["redis"], 146 | chat=chat, 147 | ) 148 | 149 | case "callback_query" | "my_chat_member" | "chat_member": 150 | if chat.type in ALLOWED_CHAT_TYPES: 151 | data["chat_model"], data["chat_settings"] = await _get_chat_model( 152 | db_pool=data["db_pool"], 153 | redis=data["redis"], 154 | chat=chat, 155 | ) 156 | 157 | case _: 158 | pass 159 | 160 | return await handler(event, data) 161 | -------------------------------------------------------------------------------- /app/bot/middlewares/check_user_middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import UTC, datetime 4 | from typing import TYPE_CHECKING, Any, Final, cast 5 | 6 | from aiogram import BaseMiddleware 7 | from aiogram.enums import ChatType 8 | from aiogram.types import Chat, Message, TelegramObject, Update, User 9 | from sqlalchemy import select, update 10 | from sqlalchemy.dialects.postgresql import insert 11 | from sqlalchemy.sql.operators import eq, ne 12 | 13 | from storages.psql.user import UserModel, UserSettingsModel 14 | from storages.redis.user import UserRD, UserSettingsRD 15 | 16 | if TYPE_CHECKING: 17 | from collections.abc import Awaitable, Callable 18 | 19 | from redis.asyncio.client import Redis 20 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 21 | 22 | # 777000 is Telegram's user id of service messages 23 | TG_SERVICE_USER_ID: Final[int] = 777000 24 | 25 | 26 | async def _create_user(*, user: User, chat: Chat, session: AsyncSession) -> UserModel: 27 | if user.username: 28 | stmt = select(UserModel).where( 29 | eq(UserModel.username, user.username), ne(UserModel.id, user.id) 30 | ) 31 | another_user: UserModel = await session.scalar(stmt) 32 | 33 | if another_user: 34 | stmt = update(UserModel).where(eq(UserModel.id, another_user.id)).values(username=None) 35 | await session.execute(stmt) 36 | 37 | stmt = select(UserModel).where(eq(UserModel.id, user.id)) 38 | user_model: UserModel | None = await session.scalar(stmt) 39 | 40 | if not user_model: 41 | stmt = ( 42 | insert(UserModel) 43 | .values( 44 | id=user.id, 45 | username=user.username, 46 | first_name=user.first_name, 47 | last_name=user.last_name, 48 | pm_active=chat.type == ChatType.PRIVATE, 49 | ) 50 | .on_conflict_do_update( 51 | index_elements=["id"], 52 | set_={ 53 | "username": user.username, 54 | "first_name": user.first_name, 55 | "last_name": user.last_name, 56 | "last_active": datetime.now(tz=UTC).replace(tzinfo=None), 57 | }, 58 | ) 59 | .returning(UserModel) 60 | ) 61 | user_model = await session.scalar(stmt) 62 | 63 | else: 64 | user_model.username = user.username 65 | user_model.first_name = user.first_name 66 | user_model.last_name = user.last_name 67 | user_model.last_active = datetime.now(tz=UTC).replace(tzinfo=None) 68 | 69 | return cast(UserModel, user_model) 70 | 71 | 72 | async def _create_user_settings(*, user_id: int, session: AsyncSession) -> UserSettingsModel: 73 | stmt = ( 74 | insert(UserSettingsModel) 75 | .values(id=user_id) 76 | .on_conflict_do_update( 77 | index_elements=["id"], set_={"language_code": UserSettingsModel.language_code} 78 | ) 79 | .returning(UserSettingsModel) 80 | ) 81 | return cast(UserSettingsModel, await session.scalar(stmt)) 82 | 83 | 84 | async def _get_user_model( 85 | *, 86 | db_pool: async_sessionmaker[AsyncSession], 87 | redis: Redis, 88 | user: User, 89 | chat: Chat, 90 | ) -> tuple[UserRD, UserSettingsRD]: 91 | user_model: UserRD | None = await UserRD.get(redis, user.id) 92 | user_settings: UserSettingsRD | None = await UserSettingsRD.get(redis, user.id) 93 | 94 | if user_model and user_settings: 95 | return user_model, user_settings 96 | 97 | async with db_pool() as session: 98 | async with session.begin(): 99 | user_model: UserModel = await _create_user(user=user, chat=chat, session=session) 100 | user_settings: UserSettingsModel = await _create_user_settings( 101 | user_id=user.id, session=session 102 | ) 103 | 104 | await session.commit() 105 | 106 | user_model: UserRD = UserRD.from_orm(cast(UserModel, user_model)) 107 | user_settings: UserSettingsRD = UserSettingsRD.from_orm( 108 | cast(UserSettingsModel, user_settings), 109 | ) 110 | 111 | await cast(UserRD, user_model).save(redis) 112 | await cast(UserSettingsRD, user_settings).save(redis) 113 | 114 | return cast(UserRD, user_model), cast(UserSettingsRD, user_settings) 115 | 116 | 117 | class CheckUserMiddleware(BaseMiddleware): 118 | async def __call__( 119 | self, 120 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 121 | event: TelegramObject, 122 | data: dict[str, Any], 123 | ) -> Any: 124 | chat: Chat = data["event_chat"] 125 | user: User = data["event_from_user"] 126 | 127 | if TYPE_CHECKING: 128 | assert isinstance(event, Update) 129 | 130 | match event.event_type: 131 | case "message": 132 | if user.is_bot is False and user.id != TG_SERVICE_USER_ID: 133 | data["user_model"], data["user_settings"] = await _get_user_model( 134 | db_pool=data["db_pool"], 135 | redis=data["redis"], 136 | user=user, 137 | chat=chat, 138 | ) 139 | 140 | msg: Message = cast(Message, event.event) 141 | 142 | if ( 143 | msg.reply_to_message 144 | and msg.reply_to_message.from_user 145 | and not msg.reply_to_message.from_user.is_bot 146 | and msg.reply_to_message.from_user.id != TG_SERVICE_USER_ID 147 | ): 148 | data["reply_user_model"], data["reply_user_settings"] = await _get_user_model( 149 | db_pool=data["db_pool"], 150 | redis=data["redis"], 151 | user=msg.reply_to_message.from_user, 152 | chat=chat, 153 | ) 154 | 155 | case "callback_query" | "my_chat_member" | "chat_member" | "inline_query": 156 | if user.is_bot is False and user.id != TG_SERVICE_USER_ID: 157 | data["user_model"], data["user_settings"] = await _get_user_model( 158 | db_pool=data["db_pool"], 159 | redis=data["redis"], 160 | user=user, 161 | chat=chat, 162 | ) 163 | 164 | case _: 165 | pass 166 | 167 | return await handler(event, data) 168 | -------------------------------------------------------------------------------- /app/bot/middlewares/throttling_middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from datetime import timedelta 5 | from typing import TYPE_CHECKING, Any, Final, TypeVar 6 | 7 | from aiogram import BaseMiddleware 8 | from aiogram.dispatcher.flags import get_flag 9 | from aiogram.types import ( 10 | CallbackQuery, 11 | ChatMemberUpdated, 12 | Message, 13 | ReactionTypeEmoji, 14 | TelegramObject, 15 | User, 16 | ) 17 | 18 | from errors.errors import MessageToReactNotFoundError 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Awaitable, Callable 22 | 23 | from redis.asyncio.client import Redis 24 | from redis.typing import ExpiryT 25 | 26 | DEFAULT_RATE_LIMIT: Final[int] = 1000 # milliseconds cooldown 27 | KeyValueT = TypeVar("KeyValueT", bound=int | str) 28 | 29 | 30 | class TTLCache: 31 | def __init__(self, redis: Redis) -> None: 32 | self.redis = redis 33 | 34 | @classmethod 35 | def key(cls, object_id: KeyValueT, throttle_key: str = "-") -> str: 36 | return f"{cls.__name__}:{object_id}:{throttle_key}" 37 | 38 | async def get(self, key: KeyValueT, throttle_key: str = "-") -> bytes | None: 39 | return await self.redis.get(self.key(key, throttle_key)) 40 | 41 | async def set( 42 | self, 43 | key: KeyValueT, 44 | time_ms: ExpiryT, 45 | value: Any, 46 | throttle_key: str = "-", 47 | ) -> bool: 48 | return await self.redis.psetex(self.key(key, throttle_key), time_ms, value) 49 | 50 | 51 | class LeakyBucket: 52 | def __init__(self, redis: Redis, limit: int, period: timedelta) -> None: 53 | self.redis = redis 54 | self.limit = limit 55 | self.period = period 56 | 57 | @classmethod 58 | def key(cls, object_id: KeyValueT) -> str: 59 | return f"{cls.__name__}:{object_id}" 60 | 61 | async def is_limit_reached(self, object_id: KeyValueT, bucket_decrement: int = 1) -> bool: 62 | key = self.key(object_id) 63 | 64 | if await self.redis.setnx(key, self.limit): 65 | await self.redis.expire(key, int(self.period.total_seconds())) 66 | 67 | bucket_value = await self.redis.get(key) or 0 68 | 69 | if int(bucket_value) > 0: 70 | await self.redis.decrby(key, bucket_decrement) 71 | return False 72 | 73 | return True 74 | 75 | 76 | class ThrottlingMiddleware(BaseMiddleware): 77 | def __init__(self, redis: Redis) -> None: 78 | self.ttl_cache = TTLCache(redis) 79 | self.leaky_bucket = LeakyBucket(redis, 4, timedelta(seconds=7)) 80 | 81 | async def __call__( 82 | self, 83 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 84 | event: TelegramObject, 85 | data: dict[str, Any], 86 | ) -> Any: 87 | user: User = data["event_from_user"] 88 | 89 | if isinstance(event, ChatMemberUpdated): 90 | return await handler(event, data) 91 | 92 | throttle_key = get_flag(data, "throttle_key", default="-") 93 | throttle_time = get_flag(data, "throttle_time", default=DEFAULT_RATE_LIMIT) 94 | bucket_decrement = get_flag(data, "bucket_decrement", default=1) 95 | 96 | if isinstance(throttle_time, timedelta): 97 | throttle_time = int(throttle_time.total_seconds() * 1000) # Convert to milliseconds 98 | 99 | if await self.ttl_cache.get(user.id, throttle_key): 100 | match event: 101 | case CallbackQuery(): 102 | await event.answer("⏳ Too fast!", show_alert=True) 103 | case Message(): 104 | with suppress(MessageToReactNotFoundError): 105 | await event.react(reaction=[ReactionTypeEmoji(emoji="🤔")]) 106 | case _: 107 | pass 108 | 109 | if throttle_key == "-": 110 | await self.ttl_cache.set( 111 | key=self.ttl_cache.key(user.id), 112 | time_ms=throttle_time, 113 | value=throttle_time, 114 | throttle_key=throttle_key, 115 | ) 116 | 117 | return None 118 | 119 | await self.ttl_cache.set( 120 | key=user.id, time_ms=throttle_time, value=throttle_time, throttle_key=throttle_key 121 | ) 122 | 123 | if await self.leaky_bucket.is_limit_reached(user.id, bucket_decrement=bucket_decrement): 124 | match event: 125 | case CallbackQuery(): 126 | await event.answer("🪣 Too fast!", show_alert=True) 127 | case Message(): 128 | with suppress(MessageToReactNotFoundError): 129 | await event.react(reaction=[ReactionTypeEmoji(emoji="🗿")]) 130 | case _: 131 | pass 132 | 133 | return None 134 | 135 | return await handler(event, data) 136 | -------------------------------------------------------------------------------- /app/bot/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "bot" 3 | version = "0.0.1" 4 | dependencies = [ 5 | "aiogram-i18n==1.4", 6 | "aiogram==3.22.0", 7 | "asyncpg==0.30.0", 8 | "fluent.runtime==0.4.0", 9 | "msgspec==0.19.0", 10 | "pydantic-settings==2.10.1", 11 | "redis[hiredis]==6.4.0", 12 | "sqlalchemy==2.0.43", 13 | "tzdata==2025.2", 14 | "uvloop==0.21.0; sys_platform == 'linux' or sys_platform == 'darwin'" 15 | ] 16 | 17 | [tool.uv] 18 | package = false 19 | -------------------------------------------------------------------------------- /app/bot/settings.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | from pydantic import SecretStr 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | from redis.asyncio import Redis 6 | from sqlalchemy import URL 7 | 8 | 9 | class PostgresSettings(BaseSettings): 10 | model_config = SettingsConfigDict(env_prefix="PSQL_") 11 | 12 | host: str 13 | port: int 14 | user: str 15 | password: SecretStr 16 | db: str 17 | 18 | 19 | class RedisSettings(BaseSettings): 20 | model_config = SettingsConfigDict(env_prefix="REDIS_") 21 | 22 | host: str 23 | port: int 24 | user: str 25 | password: SecretStr 26 | db: int 27 | 28 | 29 | class Settings(BaseSettings): 30 | model_config = SettingsConfigDict() 31 | 32 | dev: bool = False 33 | test_server: bool = False 34 | developer_id: int 35 | webhooks: bool = False 36 | bot_token: SecretStr 37 | webhook_url: SecretStr 38 | webhook_secret_token: SecretStr 39 | 40 | psql: PostgresSettings = PostgresSettings() 41 | redis: RedisSettings = RedisSettings() 42 | 43 | def psql_dsn(self) -> URL: 44 | return URL.create( 45 | drivername="postgresql+asyncpg", 46 | username=self.psql.user, 47 | password=self.psql.password.get_secret_value(), 48 | host=self.psql.host, 49 | port=self.psql.port, 50 | database=self.psql.db, 51 | ) 52 | 53 | async def redis_dsn(self) -> Redis: 54 | return Redis.from_url( 55 | "redis://{username}:{password}@{host}:{port}/{db}".format( # noqa: UP032 56 | username=self.redis.user, 57 | password=urllib.parse.quote(self.redis.password.get_secret_value()), 58 | host=self.redis.host, 59 | port=self.redis.port, 60 | db=self.redis.db, 61 | ), 62 | ) 63 | -------------------------------------------------------------------------------- /app/bot/storages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/storages/__init__.py -------------------------------------------------------------------------------- /app/bot/storages/psql/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base, close_db_pool, create_db_pool 2 | from .chat import ChatModel, ChatSettingsModel 3 | from .user import UserModel, UserSettingsModel 4 | 5 | __all__ = ( 6 | "Base", 7 | "ChatModel", 8 | "ChatSettingsModel", 9 | "UserModel", 10 | "UserSettingsModel", 11 | "close_db_pool", 12 | "create_db_pool", 13 | ) 14 | -------------------------------------------------------------------------------- /app/bot/storages/psql/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from sqlalchemy.ext.asyncio import ( 6 | AsyncEngine, 7 | AsyncSession, 8 | async_sessionmaker, 9 | create_async_engine, 10 | ) 11 | from sqlalchemy.orm import DeclarativeBase 12 | 13 | if TYPE_CHECKING: 14 | from settings import Settings 15 | 16 | 17 | class Base(DeclarativeBase): 18 | def __repr__(self) -> str: 19 | values = ", ".join( 20 | [ 21 | f"{column.name}={getattr(self, column.name)}" 22 | for column in self.__table__.columns.values() 23 | ], 24 | ) 25 | return f"{self.__tablename__}({values})" 26 | 27 | 28 | async def create_db_pool( 29 | settings: Settings, 30 | ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: 31 | engine: AsyncEngine = create_async_engine( 32 | settings.psql_dsn(), 33 | echo=settings.dev, 34 | max_overflow=10, 35 | pool_size=100, 36 | ) 37 | 38 | return engine, async_sessionmaker(engine, expire_on_commit=False) 39 | 40 | 41 | async def close_db_pool(engine: AsyncEngine) -> None: 42 | await engine.dispose() 43 | -------------------------------------------------------------------------------- /app/bot/storages/psql/chat/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat_model import ChatModel 2 | from .chat_settings_model import ChatSettingsModel 3 | 4 | __all__ = ("ChatModel", "ChatSettingsModel") 5 | -------------------------------------------------------------------------------- /app/bot/storages/psql/chat/chat_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from aiogram.enums import ChatType 4 | from sqlalchemy import BigInteger, Index, String 5 | from sqlalchemy.dialects.postgresql import CITEXT, ENUM, TIMESTAMP 6 | from sqlalchemy.orm import Mapped, mapped_column 7 | from sqlalchemy.sql import expression 8 | 9 | from storages.psql.base import Base 10 | 11 | 12 | class ChatModel(Base): 13 | __tablename__ = "chats" 14 | 15 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) 16 | chat_type: Mapped[ChatType] = mapped_column(ENUM(ChatType, name="chat_type"), nullable=False) 17 | title: Mapped[str] = mapped_column(nullable=True, server_default=expression.null()) 18 | username: Mapped[str] = mapped_column(CITEXT, nullable=True, server_default=expression.null()) 19 | member_count: Mapped[int] = mapped_column( 20 | BigInteger, nullable=False, server_default=expression.text("0") 21 | ) 22 | invite_link: Mapped[str] = mapped_column( 23 | String, nullable=True, server_default=expression.null() 24 | ) 25 | registration_datetime: Mapped[datetime] = mapped_column( 26 | TIMESTAMP(timezone=False, precision=0), 27 | nullable=False, 28 | server_default=expression.text("(now() AT TIME ZONE 'UTC'::text)"), 29 | ) 30 | migrate_from_chat_id: Mapped[int] = mapped_column( 31 | BigInteger, 32 | nullable=True, 33 | server_default=expression.null(), 34 | ) 35 | migrate_datetime: Mapped[datetime] = mapped_column( 36 | TIMESTAMP(timezone=False, precision=0), 37 | nullable=True, 38 | server_default=expression.null(), 39 | ) 40 | 41 | __table_args__ = (Index(None, username, unique=True),) 42 | -------------------------------------------------------------------------------- /app/bot/storages/psql/chat/chat_settings_model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, ForeignKey, String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | from sqlalchemy.sql import expression 4 | 5 | from storages.psql.base import Base 6 | 7 | 8 | class ChatSettingsModel(Base): 9 | __tablename__ = "chats_settings" 10 | 11 | id: Mapped[int] = mapped_column( 12 | BigInteger, 13 | ForeignKey("chats.id", onupdate="CASCADE", deferrable=True), 14 | primary_key=True, 15 | autoincrement=False, 16 | ) 17 | language_code: Mapped[str] = mapped_column( 18 | String(2), 19 | nullable=False, 20 | server_default=expression.text("'en'"), 21 | ) # language_code is set from User.language_code, which add bot to group 22 | timezone: Mapped[str] = mapped_column(String, nullable=True) 23 | -------------------------------------------------------------------------------- /app/bot/storages/psql/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .user_model import UserModel 2 | from .user_settings_model import UserSettingsModel 3 | 4 | __all__ = ("UserModel", "UserSettingsModel") 5 | -------------------------------------------------------------------------------- /app/bot/storages/psql/user/user_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import BigInteger, Index 4 | from sqlalchemy.dialects.postgresql import CITEXT, TIMESTAMP 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | from sqlalchemy.sql import expression 7 | 8 | from storages.psql.base import Base 9 | 10 | 11 | class UserModel(Base): 12 | __tablename__ = "users" 13 | 14 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) 15 | username: Mapped[str] = mapped_column(CITEXT, nullable=True) 16 | first_name: Mapped[str] = mapped_column(nullable=False) 17 | last_name: Mapped[str] = mapped_column(nullable=True, server_default=expression.null()) 18 | registration_datetime: Mapped[datetime] = mapped_column( 19 | TIMESTAMP(timezone=False, precision=0), 20 | nullable=False, 21 | server_default=expression.text("(now() AT TIME ZONE 'UTC'::text)"), 22 | ) 23 | pm_active: Mapped[bool] = mapped_column(nullable=False, server_default=expression.false()) 24 | last_active: Mapped[datetime] = mapped_column( 25 | TIMESTAMP(timezone=False, precision=0), 26 | nullable=False, 27 | server_default=expression.text("(now() AT TIME ZONE 'UTC'::text)"), 28 | ) 29 | 30 | __table_args__ = (Index(None, "username", unique=True), Index(None, "last_active")) 31 | -------------------------------------------------------------------------------- /app/bot/storages/psql/user/user_settings_model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, ForeignKey, String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | from sqlalchemy.sql import expression 4 | 5 | from storages.psql.base import Base 6 | 7 | 8 | class UserSettingsModel(Base): 9 | __tablename__ = "users_settings" 10 | 11 | id: Mapped[int] = mapped_column( 12 | BigInteger, 13 | ForeignKey("users.id", ondelete="CASCADE"), 14 | primary_key=True, 15 | autoincrement=False, 16 | ) 17 | language_code: Mapped[str] = mapped_column( 18 | String(2), 19 | nullable=False, 20 | server_default=expression.text("'en'"), 21 | ) 22 | gender: Mapped[str] = mapped_column( 23 | String(1), 24 | nullable=False, 25 | server_default=expression.text("'m'"), 26 | ) 27 | is_banned: Mapped[bool] = mapped_column(nullable=False, server_default=expression.false()) 28 | -------------------------------------------------------------------------------- /app/bot/storages/psql/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/storages/psql/utils/__init__.py -------------------------------------------------------------------------------- /app/bot/storages/psql/utils/alchemy_struct.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import msgspec 6 | 7 | if TYPE_CHECKING: 8 | from sqlalchemy.orm import DeclarativeBase 9 | 10 | 11 | class AlchemyStruct[T]: 12 | @classmethod 13 | def from_orm(cls: type[T], obj: DeclarativeBase) -> T: 14 | return msgspec.convert(obj, cls, from_attributes=True) 15 | -------------------------------------------------------------------------------- /app/bot/storages/redis/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/storages/redis/__init__.py -------------------------------------------------------------------------------- /app/bot/storages/redis/chat/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat_model import ChatModelRD 2 | from .chat_settings_model import ChatSettingsModelRD 3 | 4 | __all__ = ("ChatModelRD", "ChatSettingsModelRD") 5 | -------------------------------------------------------------------------------- /app/bot/storages/redis/chat/chat_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Final, Self 3 | 4 | import msgspec 5 | from aiogram.enums import ChatType 6 | from redis.asyncio import Redis 7 | from redis.typing import ExpiryT 8 | 9 | from storages.psql.utils.alchemy_struct import AlchemyStruct 10 | 11 | ENCODER: Final[msgspec.msgpack.Encoder] = msgspec.msgpack.Encoder() 12 | 13 | 14 | class ChatModelRD(msgspec.Struct, AlchemyStruct["ChatModelRD"], kw_only=True, array_like=True): 15 | id: int 16 | chat_type: ChatType 17 | title: str | None = msgspec.field(default=None) 18 | username: str | None = msgspec.field(default=None) 19 | registration_datetime: datetime 20 | migrate_from_chat_id: int | None = msgspec.field(default=None) 21 | migrate_datetime: datetime | None = msgspec.field(default=None) 22 | 23 | @classmethod 24 | def key(cls, chat_id: int | str) -> str: 25 | return f"{cls.__name__}:{chat_id}" 26 | 27 | @classmethod 28 | async def get(cls, redis: Redis, chat_id: int | str) -> Self | None: 29 | data = await redis.get(cls.key(chat_id)) 30 | if data: 31 | return msgspec.msgpack.decode(data, type=cls) 32 | return None 33 | 34 | async def save(self, redis: Redis, ttl: ExpiryT = timedelta(days=1)) -> int: 35 | return await redis.setex(self.key(self.id), ttl, ENCODER.encode(self)) 36 | 37 | @classmethod 38 | async def delete(cls, redis: Redis, chat_id: int | str) -> int: 39 | return await redis.delete(cls.key(chat_id)) 40 | 41 | @classmethod 42 | async def delete_all(cls, redis: Redis) -> int: 43 | keys = await redis.keys(f"{cls.__name__}:*") 44 | return await redis.delete(*keys) 45 | -------------------------------------------------------------------------------- /app/bot/storages/redis/chat/chat_settings_model.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Final, Self 3 | 4 | import msgspec 5 | from redis.asyncio import Redis 6 | from redis.typing import ExpiryT 7 | 8 | from storages.psql.utils.alchemy_struct import AlchemyStruct 9 | 10 | ENCODER: Final[msgspec.msgpack.Encoder] = msgspec.msgpack.Encoder() 11 | 12 | 13 | class ChatSettingsModelRD( 14 | msgspec.Struct, AlchemyStruct["ChatSettingsModelRD"], kw_only=True, array_like=True 15 | ): 16 | id: int 17 | language_code: str 18 | timezone: str | None = msgspec.field(default=None) 19 | 20 | @classmethod 21 | def key(cls, chat_id: int | str) -> str: 22 | return f"{cls.__name__}:{chat_id}" 23 | 24 | @classmethod 25 | async def get(cls, redis: Redis, chat_id: int | str) -> Self | None: 26 | data = await redis.get(cls.key(chat_id)) 27 | if data: 28 | return msgspec.msgpack.decode(data, type=cls) 29 | return None 30 | 31 | async def save(self, redis: Redis, ttl: ExpiryT = timedelta(days=1)) -> bool: 32 | return await redis.setex(self.key(self.id), ttl, ENCODER.encode(self)) 33 | 34 | @classmethod 35 | async def delete(cls, redis: Redis, chat_id: int | str) -> int: 36 | return await redis.delete(cls.key(chat_id)) 37 | 38 | @classmethod 39 | async def delete_all(cls, redis: Redis) -> int: 40 | keys = await redis.keys(f"{cls.__name__}:*") 41 | return await redis.delete(*keys) 42 | -------------------------------------------------------------------------------- /app/bot/storages/redis/chat_member/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat_member_model import RDChatBotModel, RDChatMemberModel 2 | 3 | __all__ = ["RDChatBotModel", "RDChatMemberModel"] 4 | -------------------------------------------------------------------------------- /app/bot/storages/redis/chat_member/chat_member_model.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime, timedelta 2 | from secrets import randbelow 3 | from typing import Final, Literal, Self, cast 4 | 5 | import msgspec 6 | from aiogram import Bot 7 | from aiogram.enums import ChatMemberStatus 8 | from aiogram.types import ( 9 | ChatMemberAdministrator, 10 | ChatMemberBanned, 11 | ChatMemberLeft, 12 | ChatMemberMember, 13 | ChatMemberOwner, 14 | ChatMemberRestricted, 15 | ResultChatMemberUnion, 16 | ) 17 | from redis.asyncio.client import Redis 18 | from redis.typing import ExpiryT 19 | 20 | TG_MIN_DATETIME: Final[datetime] = datetime(1970, 1, 1, tzinfo=UTC) 21 | ENCODER: Final[msgspec.msgpack.Encoder] = msgspec.msgpack.Encoder() 22 | 23 | 24 | class RDChatMemberModel(msgspec.Struct, kw_only=True, array_like=True): 25 | chat_id: int 26 | user_id: int 27 | status: ChatMemberStatus 28 | can_be_edited: bool | None = None 29 | is_anonymous: bool | None = None 30 | can_manage_chat: bool | None = None 31 | can_delete_messages: bool | None = None 32 | can_manage_video_chats: bool | None = None 33 | can_restrict_members: bool | None = None 34 | can_promote_members: bool | None = None 35 | can_change_info: bool | None = None 36 | can_invite_users: bool | None = None 37 | can_post_messages: bool | None = None 38 | can_edit_messages: bool | None = None 39 | can_pin_messages: bool | None = None 40 | can_manage_topics: bool | None = None 41 | custom_title: str | None = None 42 | 43 | # For restricted status 44 | can_send_messages: bool | None = None 45 | can_send_audios: bool | None = None 46 | can_send_documents: bool | None = None 47 | can_send_photos: bool | None = None 48 | can_send_videos: bool | None = None 49 | can_send_video_notes: bool | None = None 50 | can_send_voice_notes: bool | None = None 51 | can_send_polls: bool | None = None 52 | can_send_other_messages: bool | None = None 53 | can_add_web_page_previews: bool | None = None 54 | until_date: datetime | None = None 55 | 56 | @classmethod 57 | def key(cls, chat_id: int, user_id: int | Literal["*"]) -> str: 58 | return f"{cls.__name__}:{chat_id}:{user_id}" 59 | 60 | @classmethod 61 | async def get(cls, redis: Redis, chat_id: int, user_id: int) -> Self | None: 62 | data = await redis.get(cls.key(chat_id, user_id)) 63 | if data: 64 | return msgspec.msgpack.decode(data, type=cls) 65 | return None 66 | 67 | @classmethod 68 | async def get_all(cls, redis: Redis, chat_id: int) -> list[Self]: 69 | keys = await redis.keys(cls.key(chat_id, "*")) 70 | if keys: 71 | return [msgspec.msgpack.decode(await redis.get(key), type=cls) for key in keys] 72 | return [] 73 | 74 | async def save(self, redis: Redis, ttl: ExpiryT | None = None) -> Self: 75 | if self.until_date and ttl is None: 76 | if self.until_date == TG_MIN_DATETIME: 77 | ttl = timedelta(minutes=45 + randbelow(75 - 45 + 1)) 78 | 79 | else: 80 | ttl = self.until_date - datetime.now(tz=self.until_date.tzinfo) 81 | 82 | elif ttl is None: 83 | ttl = timedelta(minutes=45 + randbelow(75 - 45 + 1)) 84 | 85 | await redis.setex(self.key(self.chat_id, self.user_id), ttl, ENCODER.encode(self)) 86 | return self 87 | 88 | @classmethod 89 | async def delete(cls, redis: Redis, chat_id: int, user_id: int) -> int: 90 | return await redis.delete(cls.key(chat_id, user_id)) 91 | 92 | @classmethod 93 | async def delete_for_chat(cls, redis: Redis, chat_id: int) -> int: 94 | keys = await redis.keys(cls.key(chat_id, "*")) 95 | if keys: 96 | return await redis.delete(*keys) 97 | return 0 98 | 99 | @classmethod 100 | def resolve( 101 | cls, 102 | chat_id: int, 103 | chat_member: ( 104 | ChatMemberOwner 105 | | ChatMemberAdministrator 106 | | ChatMemberMember 107 | | ChatMemberRestricted 108 | | ChatMemberLeft 109 | | ChatMemberBanned 110 | ), 111 | ) -> Self: 112 | match chat_member: 113 | case ChatMemberOwner(): 114 | return cls.creator(chat_id=chat_id, chat_member=chat_member) 115 | 116 | case ChatMemberAdministrator(): 117 | return cls.administrator(chat_id=chat_id, chat_member=chat_member) 118 | 119 | case ChatMemberMember(): 120 | return cls.member(chat_id=chat_id, chat_member=chat_member) 121 | 122 | case ChatMemberRestricted(): 123 | return cls.restricted(chat_id=chat_id, chat_member=chat_member) 124 | 125 | case ChatMemberLeft(): 126 | return cls.left(chat_id=chat_id, chat_member=chat_member) 127 | 128 | case ChatMemberBanned(): 129 | return cls.kicked(chat_id=chat_id, chat_member=chat_member) 130 | 131 | case _: 132 | msg = "Unsupported chat member type: %s" 133 | raise TypeError(msg, type(chat_member)) 134 | 135 | @classmethod 136 | def creator(cls, chat_id: int, chat_member: ChatMemberOwner) -> Self: 137 | return cls( 138 | chat_id=chat_id, 139 | user_id=chat_member.user.id, 140 | status=chat_member.status, 141 | is_anonymous=chat_member.is_anonymous, 142 | custom_title=chat_member.custom_title, 143 | ) 144 | 145 | @classmethod 146 | def administrator(cls, chat_id: int, chat_member: ChatMemberAdministrator) -> Self: 147 | return cls( 148 | chat_id=chat_id, 149 | user_id=chat_member.user.id, 150 | status=chat_member.status, 151 | can_be_edited=chat_member.can_be_edited, 152 | is_anonymous=chat_member.is_anonymous, 153 | can_manage_chat=chat_member.can_manage_chat, 154 | can_delete_messages=chat_member.can_delete_messages, 155 | can_manage_video_chats=chat_member.can_manage_video_chats, 156 | can_restrict_members=chat_member.can_restrict_members, 157 | can_promote_members=chat_member.can_promote_members, 158 | can_change_info=chat_member.can_change_info, 159 | can_invite_users=chat_member.can_invite_users, 160 | can_post_messages=chat_member.can_post_messages, 161 | can_edit_messages=chat_member.can_edit_messages, 162 | can_pin_messages=chat_member.can_pin_messages, 163 | can_manage_topics=chat_member.can_manage_topics, 164 | custom_title=chat_member.custom_title, 165 | ) 166 | 167 | @classmethod 168 | def member(cls, chat_id: int, chat_member: ChatMemberMember) -> Self: 169 | return cls(chat_id=chat_id, user_id=chat_member.user.id, status=chat_member.status) 170 | 171 | @classmethod 172 | def restricted(cls, chat_id: int, chat_member: ChatMemberRestricted) -> Self: 173 | return cls( 174 | chat_id=chat_id, 175 | user_id=chat_member.user.id, 176 | status=chat_member.status, 177 | can_send_messages=chat_member.can_send_messages, 178 | can_send_audios=chat_member.can_send_audios, 179 | can_send_documents=chat_member.can_send_documents, 180 | can_send_photos=chat_member.can_send_photos, 181 | can_send_videos=chat_member.can_send_videos, 182 | can_send_video_notes=chat_member.can_send_video_notes, 183 | can_send_voice_notes=chat_member.can_send_voice_notes, 184 | can_send_polls=chat_member.can_send_polls, 185 | can_send_other_messages=chat_member.can_send_other_messages, 186 | can_add_web_page_previews=chat_member.can_add_web_page_previews, 187 | can_change_info=chat_member.can_change_info, 188 | can_invite_users=chat_member.can_invite_users, 189 | can_pin_messages=chat_member.can_pin_messages, 190 | can_manage_topics=chat_member.can_manage_topics, 191 | until_date=chat_member.until_date, 192 | ) 193 | 194 | @classmethod 195 | def left(cls, chat_id: int, chat_member: ChatMemberLeft) -> Self: 196 | return cls(chat_id=chat_id, user_id=chat_member.user.id, status=chat_member.status) 197 | 198 | @classmethod 199 | def kicked(cls, chat_id: int, chat_member: ChatMemberBanned) -> Self: 200 | return cls( 201 | chat_id=chat_id, 202 | user_id=chat_member.user.id, 203 | status=chat_member.status, 204 | until_date=chat_member.until_date, 205 | ) 206 | 207 | 208 | class RDChatBotModel(RDChatMemberModel): 209 | @classmethod 210 | async def get_or_create(cls, redis: Redis, chat_id: int, bot: Bot) -> Self: 211 | bot_chat_member: Self | None = await cls.get(redis, chat_id, bot.id) 212 | 213 | if not bot_chat_member: 214 | bot_chat_member: ResultChatMemberUnion = await bot.get_chat_member(chat_id, bot.id) 215 | 216 | bot_chat_member = await cls.resolve( 217 | chat_id=chat_id, 218 | chat_member=cast(ResultChatMemberUnion, bot_chat_member), 219 | ).save(redis) 220 | 221 | return bot_chat_member 222 | -------------------------------------------------------------------------------- /app/bot/storages/redis/user/__init__.py: -------------------------------------------------------------------------------- 1 | from .user_model import UserRD 2 | from .user_settings_model import UserSettingsRD 3 | 4 | __all__ = ("UserRD", "UserSettingsRD") 5 | -------------------------------------------------------------------------------- /app/bot/storages/redis/user/user_model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Final, Self 3 | 4 | import msgspec.msgpack 5 | from redis.asyncio import Redis 6 | from redis.typing import ExpiryT 7 | 8 | from storages.psql.utils.alchemy_struct import AlchemyStruct 9 | 10 | ENCODER: Final[msgspec.msgpack.Encoder] = msgspec.msgpack.Encoder() 11 | 12 | 13 | class UserRD(msgspec.Struct, AlchemyStruct["UserRD"], kw_only=True, array_like=True): 14 | id: int 15 | username: str | None = msgspec.field(default=None) 16 | first_name: str 17 | last_name: str | None = msgspec.field(default=None) 18 | registration_datetime: datetime 19 | pm_active: bool 20 | 21 | @classmethod 22 | def key(cls, user_id: int | str) -> str: 23 | return f"{cls.__name__}:{user_id}" 24 | 25 | @classmethod 26 | async def get(cls, redis: Redis, user_id: int | str) -> Self | None: 27 | data = await redis.get(cls.key(user_id)) 28 | if data: 29 | return msgspec.msgpack.decode(data, type=cls) 30 | return None 31 | 32 | async def save(self, redis: Redis, ttl: ExpiryT = timedelta(days=1)) -> str: 33 | return await redis.setex(self.key(self.id), ttl, ENCODER.encode(self)) 34 | 35 | @classmethod 36 | async def delete(cls, redis: Redis, user_id: int | str) -> int: 37 | return await redis.delete(cls.key(user_id)) 38 | 39 | @classmethod 40 | async def delete_all(cls, redis: Redis) -> int: 41 | keys = await redis.keys(f"{cls.__name__}:*") 42 | return await redis.delete(*keys) if keys else 0 43 | -------------------------------------------------------------------------------- /app/bot/storages/redis/user/user_settings_model.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Final, Self 3 | 4 | import msgspec 5 | from redis.asyncio import Redis 6 | from redis.typing import ExpiryT 7 | 8 | from storages.psql.utils.alchemy_struct import AlchemyStruct 9 | 10 | ENCODER: Final[msgspec.msgpack.Encoder] = msgspec.msgpack.Encoder() 11 | 12 | 13 | class UserSettingsRD( 14 | msgspec.Struct, 15 | AlchemyStruct["UserSettingsRD"], 16 | kw_only=True, 17 | array_like=True, 18 | ): 19 | id: int 20 | language_code: str = msgspec.field(default="en") 21 | gender: str = msgspec.field(default="m") 22 | is_banned: bool = msgspec.field(default=False) 23 | 24 | @classmethod 25 | def key(cls, user_id: int | str) -> str: 26 | return f"{cls.__name__}:{user_id}" 27 | 28 | @classmethod 29 | async def get(cls, redis: Redis, user_id: int | str) -> Self | None: 30 | data = await redis.get(cls.key(user_id)) 31 | if data: 32 | return msgspec.msgpack.decode(data, type=cls) 33 | return None 34 | 35 | async def save(self, redis: Redis, ttl: ExpiryT = timedelta(days=1)) -> str: 36 | return await redis.setex(self.key(self.id), ttl, ENCODER.encode(self)) 37 | 38 | @classmethod 39 | async def delete(cls, redis: Redis, user_id: int | str) -> int: 40 | return await redis.delete(cls.key(user_id)) 41 | 42 | @classmethod 43 | async def delete_all(cls, redis: Redis) -> int: 44 | keys = await redis.keys(f"{cls.__name__}:*") 45 | return await redis.delete(*keys) 46 | -------------------------------------------------------------------------------- /app/bot/stub.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | from contextlib import contextmanager 3 | from typing import Any, Literal 4 | 5 | from aiogram_i18n import LazyProxy 6 | 7 | class I18nContext(I18nStub): 8 | def get(self, key: str, /, **kwargs: Any) -> str: ... 9 | async def set_locale(self, locale: str, **kwargs: Any) -> None: ... 10 | @contextmanager 11 | def use_locale(self, locale: str) -> Generator[I18nContext]: ... 12 | @contextmanager 13 | def use_context(self, **kwargs: Any) -> Generator[I18nContext]: ... 14 | def set_context(self, **kwargs: Any) -> None: ... 15 | 16 | class LazyFactory(I18nStub): 17 | key_separator: str 18 | 19 | def set_separator(self, key_separator: str) -> None: ... 20 | def __call__(self, key: str, /, **kwargs: dict[str, Any]) -> LazyProxy: ... 21 | 22 | L: LazyFactory 23 | 24 | class I18nStub: 25 | class __Message: 26 | @staticmethod 27 | def deprecated(**kwargs: Any) -> Literal["🗑 Message deprecated."]: ... 28 | 29 | message = __Message() 30 | 31 | class __Close: 32 | @staticmethod 33 | def windows(**kwargs: Any) -> Literal["❌ Close"]: ... 34 | 35 | close = __Close() 36 | 37 | class __Window: 38 | @staticmethod 39 | def closed(**kwargs: Any) -> Literal["✅ Closed"]: ... 40 | 41 | window = __Window() 42 | 43 | class __ChatMember: 44 | @staticmethod 45 | def leave_transition(*, mention: Any, **kwargs: Any) -> Literal["Bye, { $mention }"]: ... 46 | @staticmethod 47 | def join_transition(*, mention: Any, **kwargs: Any) -> Literal["Welcome, { $mention }"]: ... 48 | 49 | chat_member = __ChatMember() 50 | 51 | class __MyChatMember: 52 | @staticmethod 53 | def promoted_transition( 54 | *, 55 | can_delete_messages: Any, 56 | can_restrict_members: Any, 57 | can_invite_users: Any, 58 | **kwargs: Any, 59 | ) -> Literal["❤️ Thank you for promoting me to an administrator!"]: ... 60 | @staticmethod 61 | def join_transition( 62 | *, 63 | can_delete_messages: Any, 64 | can_restrict_members: Any, 65 | can_invite_users: Any, 66 | **kwargs: Any, 67 | ) -> Literal["❤️ Thank you for adding me to the chat."]: ... 68 | @staticmethod 69 | def demoted_transition( 70 | **kwargs: Any, 71 | ) -> Literal["My administrator rights have been revoked."]: ... 72 | 73 | my_chat_member = __MyChatMember() 74 | 75 | class __ChangeLanguage: 76 | @staticmethod 77 | def button(**kwargs: Any) -> Literal["🌐 Change language"]: ... 78 | 79 | change_language = __ChangeLanguage() 80 | 81 | class __Start: 82 | @staticmethod 83 | def start_text( 84 | *, 85 | user_mention: Any, 86 | **kwargs: Any, 87 | ) -> Literal["👋 Hi, { $user_mention }"]: ... 88 | 89 | start = __Start() 90 | 91 | class __Settings: 92 | @staticmethod 93 | def language(**kwargs: Any) -> Literal["language"]: ... 94 | @staticmethod 95 | def lang(**kwargs: Any) -> Literal["lang"]: ... 96 | 97 | class __SelectLanguage: 98 | @staticmethod 99 | def code(*, language_code: Any, **kwargs: Any) -> Literal["{ $language_code ->"]: ... 100 | @staticmethod 101 | def text(**kwargs: Any) -> Literal["💁\u200d♂️ Choose a language:"]: ... 102 | @staticmethod 103 | def changed( 104 | *, 105 | language_code: Any, 106 | **kwargs: Any, 107 | ) -> Literal["✅ Language changed to: { $language_code ->"]: ... 108 | @staticmethod 109 | def goto_start(**kwargs: Any) -> Literal["↪️ Go to start"]: ... 110 | 111 | select_language = __SelectLanguage() 112 | 113 | settings = __Settings() 114 | -------------------------------------------------------------------------------- /app/bot/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/bot/utils/__init__.py -------------------------------------------------------------------------------- /app/bot/utils/callback_data_prefix_enums.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class CallbackDataPrefix(StrEnum): 5 | language_window = "language_window" # LanguageWindowCB: language_window 6 | select_language = "select_language" # SelectLanguageCB: select_language 7 | goto_start = "goto_start" # GOTOStartCB: start 8 | universal_close = "universal_close" # UniversalCloseCB: universal_close 9 | -------------------------------------------------------------------------------- /app/bot/utils/fsm_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram_i18n.managers.base import BaseManager 6 | 7 | from storages.redis.user.user_settings_model import UserSettingsRD 8 | 9 | if TYPE_CHECKING: 10 | from aiogram.types import User 11 | from redis.asyncio import Redis 12 | 13 | 14 | class FSMManager(BaseManager): 15 | key: str 16 | 17 | def __init__(self, key: str = "locale") -> None: 18 | super().__init__() 19 | self.key = key 20 | 21 | async def get_locale( 22 | self, 23 | event_from_user: User, 24 | redis: Redis, 25 | user_settings: UserSettingsRD | None = None, 26 | ) -> str: 27 | if user_settings: 28 | locale: str = user_settings.language_code 29 | 30 | else: 31 | user_settings = await UserSettingsRD.get(redis, event_from_user.id) 32 | 33 | if user_settings: 34 | locale: str = user_settings.language_code 35 | 36 | else: 37 | locale: str = self.default_locale 38 | 39 | return locale 40 | 41 | async def set_locale(self, locale: str) -> None: ... 42 | -------------------------------------------------------------------------------- /app/migrations/.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | **/*.py[cod] 3 | **/__pycache__/ 4 | Dockerfile 5 | .dockerignore 6 | -------------------------------------------------------------------------------- /app/migrations/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.6-slim 2 | 3 | ENV PYTHONUNBUFFERED=1 \ 4 | PIP_DISABLE_PIP_VERSION_CHECK=1 5 | 6 | ARG USER_ID=${USER_ID:-999} 7 | ARG GROUP_ID=${GROUP_ID:-999} 8 | ARG USER_NAME=${USER_NAME:-migrator} 9 | 10 | WORKDIR /app 11 | 12 | RUN if [ "$USER_NAME" != "root" ]; then \ 13 | echo "Creating non-root user: $USER_NAME" && \ 14 | groupadd --system --gid=${GROUP_ID} ${USER_NAME} && \ 15 | useradd --system --shell /bin/false --no-log-init --gid=${GROUP_ID} --uid=${USER_ID} ${USER_NAME} && \ 16 | chown ${USER_NAME}:${USER_NAME} /app ; \ 17 | else \ 18 | echo "Running as root, skipping user creation"; \ 19 | fi 20 | 21 | USER ${USER_NAME} 22 | 23 | COPY --chown=${USER_NAME}:${USER_NAME} pyproject.toml /app/ 24 | 25 | RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \ 26 | uv --no-cache sync --no-dev 27 | 28 | COPY --chown=${USER_NAME}:${USER_NAME} . /app/ 29 | -------------------------------------------------------------------------------- /app/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. 2 | -------------------------------------------------------------------------------- /app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/migrations/__init__.py -------------------------------------------------------------------------------- /app/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = . 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>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | timezone = UTC 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 .\migrations\/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:.\migrations\/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 | [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 | # Logging configuration 70 | [loggers] 71 | keys = root,sqlalchemy,alembic 72 | 73 | [handlers] 74 | keys = console 75 | 76 | [formatters] 77 | keys = generic 78 | 79 | [logger_root] 80 | level = WARN 81 | handlers = console 82 | qualname = 83 | 84 | [logger_sqlalchemy] 85 | level = WARN 86 | handlers = 87 | qualname = sqlalchemy.engine 88 | 89 | [logger_alembic] 90 | level = INFO 91 | handlers = 92 | qualname = alembic 93 | 94 | [handler_console] 95 | class = StreamHandler 96 | args = (sys.stderr,) 97 | level = NOTSET 98 | formatter = generic 99 | 100 | [formatter_generic] 101 | format = %(levelname)-5.5s [%(name)s] %(message)s 102 | datefmt = %H:%M:%S 103 | -------------------------------------------------------------------------------- /app/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy.engine import Connection 6 | 7 | from settings import Settings 8 | from storages.psql.base import Base, create_db_pool 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | target_metadata = Base.metadata 24 | 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | 31 | 32 | def run_migrations_offline() -> None: 33 | """ 34 | Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def do_run_migrations(connection: Connection) -> None: 58 | context.configure(connection=connection, target_metadata=target_metadata) 59 | 60 | with context.begin_transaction(): 61 | context.run_migrations() 62 | 63 | 64 | async def run_async_migrations(settings: Settings) -> None: 65 | """ 66 | In this scenario we need to create an Engine 67 | and associate a connection with the context. 68 | 69 | """ 70 | engine, _async_session_maker = await create_db_pool(settings) 71 | 72 | async with engine.connect() as connection: 73 | await connection.run_sync(do_run_migrations) 74 | 75 | await engine.dispose() 76 | 77 | 78 | def run_migrations_online() -> None: 79 | """Run migrations in 'online' mode.""" 80 | settings = Settings() 81 | asyncio.run(run_async_migrations(settings)) 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /app/migrations/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "migrations" 3 | version = "0.0.1" 4 | dependencies = [ 5 | "aiogram-i18n==1.4", 6 | "alembic==1.16.4", 7 | "asyncpg==0.30.0", 8 | "msgspec==0.19.0", 9 | "pydantic-settings==2.10.1", 10 | "redis[hiredis]==6.4.0", 11 | "sqlalchemy==2.0.43", 12 | ] 13 | 14 | [tool.uv] 15 | package = false 16 | -------------------------------------------------------------------------------- /app/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """ 2 | ${message}. 3 | 4 | Revision ID: ${up_revision} 5 | Revises: ${down_revision | comma,n} 6 | Create Date: ${create_date} 7 | 8 | """ 9 | from collections.abc import Sequence 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | ${imports if imports else ""} 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: str | None = ${repr(down_revision)} 17 | branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} 18 | depends_on: 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 | -------------------------------------------------------------------------------- /app/migrations/versions/000000000000_initial.py: -------------------------------------------------------------------------------- 1 | """ 2 | Initial. 3 | 4 | Revision ID: 000000000000 5 | Revises: 6 | Create Date: 2025-08-26 09:55:53.352384+00:00 7 | 8 | """ 9 | 10 | from collections.abc import Sequence 11 | 12 | import sqlalchemy as sa 13 | from alembic import op 14 | from sqlalchemy.dialects import postgresql 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "000000000000" 18 | down_revision: str | None = None 19 | branch_labels: str | Sequence[str] | None = None 20 | depends_on: str | Sequence[str] | None = None 21 | 22 | 23 | def upgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table( 26 | "chats", 27 | sa.Column("id", sa.BigInteger(), autoincrement=False, nullable=False), 28 | sa.Column( 29 | "chat_type", 30 | postgresql.ENUM( 31 | "SENDER", "PRIVATE", "GROUP", "SUPERGROUP", "CHANNEL", name="chat_type" 32 | ), 33 | nullable=False, 34 | ), 35 | sa.Column("title", sa.String(), server_default=sa.text("NULL"), nullable=True), 36 | sa.Column("username", postgresql.CITEXT(), server_default=sa.text("NULL"), nullable=True), 37 | sa.Column("member_count", sa.BigInteger(), server_default=sa.text("0"), nullable=False), 38 | sa.Column("invite_link", sa.String(), server_default=sa.text("NULL"), nullable=True), 39 | sa.Column( 40 | "registration_datetime", 41 | postgresql.TIMESTAMP(precision=0), 42 | server_default=sa.text("(now() AT TIME ZONE 'UTC'::text)"), 43 | nullable=False, 44 | ), 45 | sa.Column( 46 | "migrate_from_chat_id", sa.BigInteger(), server_default=sa.text("NULL"), nullable=True 47 | ), 48 | sa.Column( 49 | "migrate_datetime", 50 | postgresql.TIMESTAMP(precision=0), 51 | server_default=sa.text("NULL"), 52 | nullable=True, 53 | ), 54 | sa.PrimaryKeyConstraint("id"), 55 | ) 56 | op.create_index(op.f("ix_chats_username"), "chats", ["username"], unique=True) 57 | op.create_table( 58 | "users", 59 | sa.Column("id", sa.BigInteger(), autoincrement=False, nullable=False), 60 | sa.Column("username", postgresql.CITEXT(), nullable=True), 61 | sa.Column("first_name", sa.String(), nullable=False), 62 | sa.Column("last_name", sa.String(), server_default=sa.text("NULL"), nullable=True), 63 | sa.Column( 64 | "registration_datetime", 65 | postgresql.TIMESTAMP(precision=0), 66 | server_default=sa.text("(now() AT TIME ZONE 'UTC'::text)"), 67 | nullable=False, 68 | ), 69 | sa.Column("pm_active", sa.Boolean(), server_default=sa.text("false"), nullable=False), 70 | sa.Column( 71 | "last_active", 72 | postgresql.TIMESTAMP(precision=0), 73 | server_default=sa.text("(now() AT TIME ZONE 'UTC'::text)"), 74 | nullable=False, 75 | ), 76 | sa.PrimaryKeyConstraint("id"), 77 | ) 78 | op.create_index(op.f("ix_users_last_active"), "users", ["last_active"], unique=False) 79 | op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) 80 | op.create_table( 81 | "chats_settings", 82 | sa.Column("id", sa.BigInteger(), autoincrement=False, nullable=False), 83 | sa.Column( 84 | "language_code", sa.String(length=2), server_default=sa.text("'en'"), nullable=False 85 | ), 86 | sa.Column("timezone", sa.String(), nullable=True), 87 | sa.ForeignKeyConstraint(["id"], ["chats.id"], onupdate="CASCADE", deferrable=True), 88 | sa.PrimaryKeyConstraint("id"), 89 | ) 90 | op.create_table( 91 | "users_settings", 92 | sa.Column("id", sa.BigInteger(), autoincrement=False, nullable=False), 93 | sa.Column( 94 | "language_code", sa.String(length=2), server_default=sa.text("'en'"), nullable=False 95 | ), 96 | sa.Column("gender", sa.String(length=1), server_default=sa.text("'m'"), nullable=False), 97 | sa.Column("is_banned", sa.Boolean(), server_default=sa.text("false"), nullable=False), 98 | sa.ForeignKeyConstraint(["id"], ["users.id"], ondelete="CASCADE"), 99 | sa.PrimaryKeyConstraint("id"), 100 | ) 101 | # ### end Alembic commands ### 102 | 103 | 104 | def downgrade() -> None: 105 | # ### commands auto generated by Alembic - please adjust! ### 106 | op.drop_table("users_settings") 107 | op.drop_table("chats_settings") 108 | op.drop_index(op.f("ix_users_username"), table_name="users") 109 | op.drop_index(op.f("ix_users_last_active"), table_name="users") 110 | op.drop_table("users") 111 | op.drop_index(op.f("ix_chats_username"), table_name="chats") 112 | op.drop_table("chats") 113 | # ### end Alembic commands ### 114 | -------------------------------------------------------------------------------- /app/migrations/versions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/app/migrations/versions/__init__.py -------------------------------------------------------------------------------- /caddy/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory 3 | admin off 4 | } 5 | 6 | (common) { 7 | header /* { 8 | -Server 9 | } 10 | } 11 | 12 | (error) { 13 | handle_errors { 14 | header -Server 15 | respond 401 16 | } 17 | } 18 | 19 | example.com { 20 | import common 21 | import error 22 | 23 | log { 24 | output stderr 25 | format filter { 26 | wrap console 27 | fields { 28 | request>headers>X-Telegram-Bot-Api-Secret-Token delete 29 | } 30 | } 31 | level INFO 32 | } 33 | 34 | log { 35 | output file /var/log/caddy/example.com.log { 36 | roll_size 100MiB 37 | } 38 | format filter { 39 | wrap json 40 | fields { 41 | request>headers>X-Telegram-Bot-Api-Secret-Token delete 42 | } 43 | } 44 | level INFO 45 | } 46 | 47 | @tg_bot_api { 48 | method POST 49 | path /webhook/* 50 | remote_ip 149.154.160.0/20 91.108.4.0/22 51 | } 52 | 53 | route @tg_bot_api { 54 | reverse_proxy { 55 | to /bot:8080 56 | } 57 | } 58 | 59 | respond 404 { 60 | close 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /caddy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrew000/aiogram-template/64f4c8b2d7952f35f3f28e95bf89b1530156c535/caddy/public/favicon.ico -------------------------------------------------------------------------------- /caddy/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Aiogram Template 8 | 9 | 10 | 11 |
12 |

Just a simple welcome page, provided by Aiogram Template

13 |
14 | 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bot: 3 | build: 4 | context: app/bot 5 | dockerfile: Dockerfile 6 | 7 | env_file: .env.docker 8 | 9 | networks: 10 | - aiogram 11 | 12 | stop_signal: SIGINT 13 | 14 | depends_on: 15 | - database 16 | - redis 17 | # - caddy # Uncomment if you want to use Caddy as a reverse proxy 18 | 19 | restart: always 20 | 21 | entrypoint: [ ".venv/bin/python", "main.py" ] 22 | 23 | migrations: 24 | # Do not run this container directly. Use `make` to run migrations. 25 | build: 26 | context: app/migrations 27 | dockerfile: Dockerfile 28 | 29 | env_file: .env.docker 30 | 31 | networks: 32 | - aiogram 33 | 34 | stop_signal: SIGINT 35 | 36 | volumes: 37 | - ./app/migrations/versions:/app/versions 38 | - ./app/bot/settings.py:/app/settings.py:ro 39 | - ./app/bot/storages/psql:/app/storages/psql:ro 40 | 41 | depends_on: 42 | - database 43 | 44 | restart: always 45 | 46 | database: 47 | image: postgres:17.6 48 | 49 | environment: 50 | POSTGRES_USER: $PSQL_USER 51 | POSTGRES_PASSWORD_FILE: /run/secrets/postgres-passwd 52 | POSTGRES_DB: $PSQL_DB 53 | 54 | secrets: 55 | - postgres-passwd 56 | 57 | networks: 58 | - aiogram 59 | 60 | ports: 61 | - "127.0.0.1:$PSQL_EXTERNAL_PORT:5432" 62 | 63 | volumes: 64 | - ./psql/data:/var/lib/postgresql/data 65 | - ./psql/db-init-scripts:/docker-entrypoint-initdb.d:ro 66 | 67 | restart: always 68 | 69 | redis: 70 | image: redis:8.2.1 71 | 72 | command: 73 | - --port 6379 74 | - --protected-mode no 75 | - --loglevel notice 76 | - --requirepass $REDIS_PASSWORD 77 | - --maxmemory 256MB 78 | - --save 60 300 79 | - --dir /data 80 | - --dbfilename dump.rdb 81 | - --rdbcompression yes 82 | - --rdbchecksum yes 83 | - --always-show-logo yes 84 | 85 | networks: 86 | - aiogram 87 | 88 | ports: 89 | - "127.0.0.1:$REDIS_EXTERNAL_PORT:6379" 90 | 91 | volumes: 92 | - ./redis/data:/data 93 | 94 | restart: always 95 | 96 | entrypoint: [ "redis-server" ] 97 | 98 | caddy: # Uncomment if you want to use Caddy as a reverse proxy 99 | image: caddy:2.10.2 100 | 101 | networks: 102 | - aiogram 103 | 104 | ports: 105 | - "80:80" 106 | - "443:443" 107 | 108 | volumes: 109 | - ./caddy/Caddyfile:/etc/caddy/Caddyfile 110 | - ./caddy/data:/data 111 | - ./caddy/config:/config 112 | - ./caddy/public:/usr/share/caddy/ 113 | 114 | restart: always 115 | 116 | networks: 117 | aiogram: 118 | 119 | secrets: 120 | postgres-passwd: 121 | environment: PSQL_PASSWORD 122 | -------------------------------------------------------------------------------- /psql/db-init-scripts/citext.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE EXTENSION IF NOT EXISTS citext;" 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiogram-template" 3 | version = "0.0.1" 4 | requires-python = "==3.13.6" 5 | dependencies = [ 6 | "bot", 7 | "migrations" 8 | ] 9 | 10 | [tool.uv.sources] 11 | bot = { workspace = true } 12 | migrations = { workspace = true } 13 | 14 | [tool.uv] 15 | package = false 16 | 17 | [tool.uv.workspace] 18 | members = ["app/*"] 19 | 20 | [build-system] 21 | requires = ["uv_build>=0.8.0,<0.9.0"] 22 | build-backend = "uv_build" 23 | 24 | [project.optional-dependencies] 25 | dev = [ 26 | "FTL-Extract==0.9.0a10", 27 | "pre-commit==4.3.0", 28 | ] 29 | lint = [ 30 | "isort==6.0.1", 31 | "ruff==0.12.10", 32 | "mypy==1.17.1", 33 | "types-pytz==2025.2.0.20250809", 34 | ] 35 | 36 | [tool.isort] 37 | py_version = 313 38 | src_paths = ["app"] 39 | line_length = 100 40 | multi_line_output = 3 41 | force_grid_wrap = 0 42 | include_trailing_comma = true 43 | split_on_trailing_comma = false 44 | single_line_exclusions = ["."] 45 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 46 | skip_gitignore = true 47 | extend_skip = ["__pycache__"] 48 | extend_skip_glob = ["app/bot/locales/*"] 49 | known_first_party = [ 50 | "errors", 51 | "filters", 52 | "handlers", 53 | "middlewares", 54 | "settings", 55 | "storages", 56 | "stub", 57 | "utils" 58 | ] 59 | 60 | [tool.ruff] 61 | src = ["app"] 62 | line-length = 100 63 | exclude = [ 64 | ".git", 65 | ".mypy_cache", 66 | ".ruff_cache", 67 | "__pypackages__", 68 | "__pycache__", 69 | "venv", 70 | ".venv", 71 | ] 72 | 73 | [tool.ruff.lint] 74 | select = ["ALL"] 75 | ignore = [ 76 | "A003", 77 | "ANN002", "ANN003", "ANN401", 78 | "C901", 79 | "COM812", 80 | "CPY001", 81 | "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D203", "D205", "D212", 82 | "DOC201", "DOC501", 83 | "ERA001", 84 | "FA100", "FA102", 85 | "FBT001", "FBT002", 86 | "FIX002", 87 | "I001", 88 | "PLC2801", 89 | "PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "PLR1702", "PLR5501", "PLR6301", 90 | "PLW0120", 91 | "RUF001", "RUF029", 92 | "TC006", 93 | "TD002", "TD003" 94 | ] 95 | 96 | [tool.ruff.lint.extend-per-file-ignores] 97 | "stub.pyi" = ["A002", "E501"] 98 | 99 | [tool.ruff.format] 100 | quote-style = "double" 101 | indent-style = "space" 102 | skip-magic-trailing-comma = false 103 | line-ending = "auto" 104 | 105 | [tool.mypy] 106 | python_version = "3.13" 107 | mypy_path = "app/bot" 108 | packages = ["app"] 109 | plugins = [ 110 | "pydantic.mypy", 111 | "sqlalchemy.ext.mypy.plugin", 112 | ] 113 | allow_redefinition = true 114 | check_untyped_defs = true 115 | disallow_any_generics = true 116 | disallow_incomplete_defs = true 117 | disallow_untyped_calls = true 118 | disallow_untyped_defs = true 119 | extra_checks = true 120 | follow_imports = "normal" 121 | follow_imports_for_stubs = true 122 | ignore_missing_imports = false 123 | no_implicit_optional = true 124 | no_implicit_reexport = true 125 | pretty = true 126 | show_absolute_path = true 127 | show_error_codes = true 128 | show_error_context = true 129 | warn_redundant_casts = true 130 | warn_unused_configs = true 131 | warn_unused_ignores = true 132 | 133 | disable_error_code = [ 134 | "assignment", 135 | "attr-defined", 136 | "no-redef", 137 | "union-attr", 138 | ] 139 | 140 | exclude = [ 141 | "\\.?venv", 142 | "\\.idea", 143 | "\\.tests?", 144 | ] 145 | 146 | [tool.pydantic-mypy] 147 | init_forbid_extra = true 148 | init_typed = true 149 | warn_required_dynamic_aliases = true 150 | --------------------------------------------------------------------------------