├── .github └── workflows │ └── CI.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── configs └── settings.toml ├── docker ├── Dockerfile └── docker-compose.yaml ├── poetry.lock ├── pyproject.toml └── src ├── __init__.py ├── core ├── __init__.py ├── config │ ├── __init__.py │ ├── parser.py │ └── validator.py └── utils │ ├── __init__.py │ ├── builders.py │ └── logging.py ├── infrastructure ├── __init__.py ├── database │ ├── __init__.py │ ├── adapter │ │ ├── __init__.py │ │ └── adapter.py │ ├── interfaces │ │ ├── __init__.py │ │ └── adapter.py │ └── models │ │ ├── __init__.py │ │ ├── base.py │ │ └── schemas.py ├── scheduler │ ├── __init__.py │ ├── tasks.py │ └── tkq.py └── stream │ ├── __init__.py │ └── worker.py └── presentation ├── __init__.py ├── locales ├── de_DE │ └── main.ftl ├── en_GB │ └── main.ftl └── ru_RU │ └── main.ftl └── tgbot ├── __init__.py ├── __main__.py ├── constants.py ├── dialogs ├── __init__.py ├── create_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── delete_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── edit_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handlers.py ├── extras │ ├── __init__.py │ ├── calendar.py │ └── i18n_format.py └── main_menu │ ├── __init__.py │ ├── dialog.py │ ├── getters.py │ └── handler.py ├── handlers ├── __init__.py ├── client.py └── errors.py ├── keyboards ├── __init__.py └── inline.py ├── middlewares ├── __init__.py ├── database.py └── i18n.py └── states ├── __init__.py └── user.py /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [ "3.11" ] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Upgrade Pip 23 | run: | 24 | pip3 install --no-cache-dir --upgrade pip 25 | pip3 install --no-cache-dir setuptools wheel 26 | - name: Install Poetry 27 | run: | 28 | curl -sSL https://install.python-poetry.org | python3 - 29 | - name: Install Dependencies 30 | run: | 31 | poetry install --only dev --no-root --no-interaction --no-ansi 32 | - name: Run Ruff 33 | run: | 34 | poetry run ruff . 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # Ignore dynaconf secret files 163 | configs/.secrets.toml 164 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark 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 | # --- Help Menu --- 2 | .PHONY: help 3 | help: 4 | @echo Usage: make [COMMAND] 5 | @echo ㅤ 6 | @echo Available commands: 7 | @echo ruff Run ruff check 8 | @echo lint Reformat code 9 | @echo generate Generate alembic migrations 10 | @echo migrate Migrate with alembic 11 | @echo worker Run taskiq worker script 12 | @echo scheduler RUN taskiq scheduler script 13 | 14 | # --- Linters & Checkers --- 15 | .PHONY: ruff 16 | ruff: 17 | poetry run ruff . --fix 18 | 19 | .PHONY: lint 20 | lint: ruff 21 | 22 | # --- Alembic Utils --- 23 | .PHONY: generate 24 | generate: 25 | poetry run alembic revision --autogenerate 26 | 27 | .PHONY: migrate 28 | migrate: 29 | poetry run alembic upgrade head 30 | 31 | # --- Taskiq Scripts --- 32 | .PHONY: worker 33 | worker: 34 | poetry run taskiq worker src.infrastructure.scheduler.tkq:broker --fs-discover --reload --max-async-tasks -1 35 | 36 | .PHONY: scheduler 37 | scheduler: 38 | poetry run taskiq scheduler src.infrastructure.scheduler.tkq:scheduler --fs-discover 39 | 40 | # Написать программу, имитирующая работу калькулятора, который имеет арифметическую и функциональную часть 41 | # a - вещественное. вводится знак операции -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | https://t.me/sub_controller_bot/ 3 | https://hub.docker.com/repository/docker/markushik/controller-new/ 4 | 5 | https://opensource.org/licenses/MIT/ 6 | https://github.com/Markushik/controller-new/stargazers 7 | https://github.com/Markushik/controller-new/ 8 | https://github.com/Markushik/controller-new/ 9 | 10 | https://github.com/Markushik/controller-new/actions/ 11 | https://github.com/astral-sh/ruff/ 12 | https://github.com/grantjenks/blue 13 | 14 | > **controller** — probably the best bot for a reminder of the end of the subscription. 15 | 16 | ## 🚀 Stack 17 | 18 | ### Technologies 19 | 20 | - [Python](https://www.python.org/) – programming language 21 | - [Redis](https://redis.io/) – persistent storage 22 | - [PostgreSQL](https://www.postgresql.org/) – best relational database 23 | - [NATS JetStream](https://nats.io/) – communications system for digital systems 24 | - [Docker](https://www.docker.com/) – containerization platform 25 | 26 | ### Frameworks & Libraries 27 | 28 | - [aiogram](https://github.com/aiogram/aiogram) – async framework for Telegram Bot API 29 | - [aiogram-dialog](https://github.com/Tishka17/aiogram_dialog) – developing interactive messages 30 | - [asyncpg](https://github.com/MagicStack/asyncpg) – fast client for PostgreSQL Database 31 | - [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) – SQL toolkit & ORM 32 | - [alembic](https://github.com/sqlalchemy/alembic) – migration tool 33 | - [nats-py](https://github.com/nats-io/nats.py) - Python client for NATS 34 | - [taskiq](https://github.com/taskiq-python/taskiq) – distributed task queue 35 | - [dynaconf](https://github.com/dynaconf/dynaconf) – configuration management 36 | - [structlog](https://github.com/Delgan/loguru) – structured logging 37 | 38 | ### Auxiliary Libraries 39 | 40 | - [zstd](https://github.com/facebook/zstd) – compression technology 41 | - [ormsgpack](https://github.com/aviramha/ormsgpack) – msgpack serialization 42 | - [markupsafe](https://github.com/pallets/markupsafe) – safely add untrusted strings to HTML 43 | - [fluent.runtime](https://github.com/projectfluent/python-fluent) – localization / internationalization 44 | 45 | ## ⭐ Application Schema 46 | [![application-schema.png](https://i.postimg.cc/YCg4LRKZ/application-schema.png)](https://github.com/Markushik/controller-new/) 47 | 48 | ## 🐘 Database Schema 49 | 50 | [![database-scheme.png](https://i.postimg.cc/BbYFNnMz/database-scheme.png)](https://drawsql.app/teams/marqezs-team/diagrams/controller-new/) 51 | 52 | ## 🪛 Installation 53 | 54 | ### 🐳 Docker 55 | 56 | **1. Clone the repository:** 57 | 58 | ``` 59 | git clone https://github.com/Markushik/controller-new.git 60 | ``` 61 | 62 | **2. Create file `.secrets.toml` in folder `configs` and fill data** 63 | 64 | **3. Run the command:** 65 | 66 | ``` 67 | docker-compose up 68 | ``` 69 | 70 | ### 💻 Default 71 | 72 | **1. Clone the repository:** 73 | 74 | ``` 75 | git clone https://github.com/Markushik/controller-new.git 76 | ``` 77 | 78 | **2. Create file `.secrets.toml` in folder `configs` and fill data** 79 | 80 | **3. Bring up PostgreSQL, Redis and NATS** 81 | 82 | **4. First run the `taskiq` scripts:** 83 | 84 | ``` 85 | taskiq worker application.infrastructure.scheduler.tkq:broker --fs-discover --reload --max-async-tasks -1 86 | ``` 87 | 88 | ``` 89 | taskiq scheduler application.infrastructure.scheduler.tkq:scheduler --fs-discover 90 | ``` 91 | 92 | **5. Second run the `bot`:** 93 | 94 | ``` 95 | python -m application.tgbot 96 | ``` 97 | -------------------------------------------------------------------------------- /configs/settings.toml: -------------------------------------------------------------------------------- 1 | [development] 2 | 3 | [development.redis] 4 | REDIS_HOST = '127.0.0.1' # redis 5 | REDIS_PORT = 6379 6 | REDIS_DATABASE = 7 7 | 8 | [development.postgres] 9 | POSTGRES_HOST = '127.0.0.1' # postgres 10 | POSTGRES_PORT = 5432 11 | POSTGRES_USERNAME = 'postgres' 12 | POSTGRES_PASSWORD = 'postgres' 13 | POSTGRES_DATABASE = 'postgres' 14 | 15 | [development.nats] 16 | NATS_HOST = '127.0.0.1' # nats 17 | NATS_PORT = 4222 18 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.4-slim-buster AS builder 2 | 3 | ENV \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PYTHONUNBUFFERED=1 \ 6 | PIP_NO_CACHE_DIR=on \ 7 | PIP_ROOT_USER_ACTION=ignore 8 | 9 | ENV \ 10 | POETRY_NO_INTERACTION=1 \ 11 | POETRY_NO_ANSI=1 12 | 13 | WORKDIR . 14 | 15 | COPY poetry.lock pyproject.toml ./ 16 | 17 | RUN pip3 install --upgrade pip \ 18 | && pip3 install setuptools wheel \ 19 | && pip3 install poetry 20 | RUN poetry install --only main --no-root 21 | 22 | WORKDIR ./application/ 23 | 24 | COPY application ./application/ 25 | COPY configs ./configs/ 26 | COPY alembic.ini /alembic.ini 27 | 28 | ENTRYPOINT ["/usr/bin/env", "poetry", "run", "python3", "-m", "application.tgbot"] 29 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | container_name: 'redis' 4 | image: 'redis:7.0.12-alpine' 5 | ports: 6 | - '6379:6379' 7 | restart: 'unless-stopped' 8 | volumes: 9 | - '~/${VOLUMES_DIR}/redis-assets:/assets' 10 | postgres: 11 | container_name: 'postgres' 12 | image: 'postgres:15.3-alpine' 13 | ports: 14 | - '5432:5432' 15 | restart: 'unless-stopped' 16 | environment: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | volumes: 20 | - '~/${VOLUMES_DIR}/pg-assets:/var/lib/postgresql/assets' 21 | nats: 22 | container_name: 'nats' 23 | image: 'nats:2.9.19-alpine' 24 | restart: 'unless-stopped' 25 | ports: 26 | - '4222:4222' 27 | - '6222:6222' 28 | - '8222:8222' 29 | command: 'nats-server -js -sd /nats-assets/assets' 30 | application: 31 | container_name: 'application' 32 | build: 33 | context: . 34 | dockerfile: Dockerfile 35 | stop_signal: SIGTERM 36 | restart: 'unless-stopped' 37 | depends_on: 38 | - nats 39 | - redis 40 | - postgres -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "controller-new" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Mark <74201924+Markushik@users.noreply.github.com>"] 6 | readme = "README.md" 7 | packages = [{include = "controller_new"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | sqlalchemy = "^2.0.18" 12 | nats-py = "^2.3.1" 13 | fluent-runtime = "^0.4.0" 14 | dynaconf = { extras = ["redis"], version = "^3.1.12" } 15 | ormsgpack = "^1.2.6" 16 | asyncpg = "^0.28.0" 17 | alembic = "^1.11.1" 18 | pathvalidate = "^3.0.0" 19 | markupsafe = "^2.1.3" 20 | taskiq-nats = "^0.3.0" 21 | uuid6 = "^2023.5.2" 22 | adaptix = "^3.0.0a4" 23 | orjson = "^3.9.5" 24 | aiogram-dialog = "^2.0.0" 25 | zstd = "^1.5.5.1" 26 | structlog = "^23.1.0" 27 | colorama = "^0.4.6" 28 | loguru = "^0.7.2" 29 | taskiq = {extras = ["reload"], version = "^0.9.0"} 30 | 31 | [tool.poetry.group.dev.dependencies] 32 | ruff = "^0.0.280" 33 | 34 | [tool.ruff] 35 | line-length = 120 36 | 37 | [build-system] 38 | requires = ["poetry-core"] 39 | build-backend = "poetry.core.masonry.api" 40 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/__init__.py -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/config/__init__.py -------------------------------------------------------------------------------- /src/core/config/parser.py: -------------------------------------------------------------------------------- 1 | from dynaconf import Dynaconf 2 | 3 | from .validator import validators 4 | 5 | settings = Dynaconf( 6 | settings_files=[ 7 | 'configs//settings.toml', 8 | 'configs//.secrets.toml' 9 | ], 10 | validators=validators, 11 | environments=True, 12 | ) 13 | -------------------------------------------------------------------------------- /src/core/config/validator.py: -------------------------------------------------------------------------------- 1 | from aiogram.utils.token import validate_token 2 | from dynaconf import Validator 3 | 4 | validators = [ 5 | Validator( 6 | names='API_TOKEN', condition=validate_token, must_exist=True 7 | ), 8 | Validator( 9 | names='redis.REDIS_HOST', is_type_of=int, must_exist=True 10 | ), 11 | Validator( 12 | names='redis.REDIS_PORT', is_type_of=int, must_exist=True 13 | ), 14 | Validator( 15 | names='redis.REDIS_DATABASE', is_type_of=int 16 | ), 17 | Validator( 18 | names='postgres.POSTGRES_HOST', is_type_of=str, must_exist=True 19 | ), 20 | Validator( 21 | names='postgres.POSTGRES_PORT', is_type_of=int, must_exist=True 22 | ), 23 | Validator( 24 | names='postgres.POSTGRES_USERNAME', is_type_of=str, must_exist=True 25 | ), 26 | Validator( 27 | names='postgres.POSTGRES_PASSWORD', is_type_of=str, must_exist=True 28 | ), 29 | Validator( 30 | names='postgres.POSTGRES_DATABASE', is_type_of=str, must_exist=True 31 | ), 32 | Validator( 33 | names='nats.NATS_HOST', is_type_of=str, must_exist=True 34 | ), 35 | Validator( 36 | names='nats.NATS_PORT', is_type_of=int, must_exist=True 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /src/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/core/utils/__init__.py -------------------------------------------------------------------------------- /src/core/utils/builders.py: -------------------------------------------------------------------------------- 1 | from yarl import URL 2 | 3 | from ..config.parser import settings 4 | 5 | 6 | def create_postgres_url() -> URL: 7 | return URL.build( 8 | scheme='postgresql+asyncpg', 9 | host=settings['postgres.POSTGRES_HOST'], 10 | port=settings['postgres.POSTGRES_PORT'], 11 | user=settings['postgres.POSTGRES_USERNAME'], 12 | password=settings['postgres.POSTGRES_PASSWORD'], 13 | path=f"/{settings['postgres.POSTGRES_DATABASE']}", 14 | ) 15 | 16 | 17 | def create_redis_url() -> URL: 18 | return URL.build( 19 | scheme='redis', 20 | host=settings['redis.REDIS_HOST'], 21 | port=settings['redis.REDIS_PORT'], 22 | path=f"/{settings['redis.REDIS_DATABASE']}", 23 | ) 24 | 25 | 26 | def create_nats_url() -> URL: 27 | return URL.build( 28 | scheme='nats', 29 | host=settings['nats.NATS_HOST'], 30 | port=settings['nats.NATS_PORT'] 31 | ) 32 | -------------------------------------------------------------------------------- /src/core/utils/logging.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | 4 | from loguru import logger 5 | 6 | 7 | class InterceptHandler(logging.Handler): 8 | def emit(self, record: logging.LogRecord) -> None: 9 | # Get corresponding Loguru level if it exists. 10 | level: str | int 11 | try: 12 | level = logger.level(record.levelname).name 13 | except ValueError: 14 | level = record.levelno 15 | 16 | # Find caller from where originated the logged message. 17 | frame, depth = inspect.currentframe(), 0 18 | while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): 19 | frame = frame.f_back 20 | depth += 1 21 | 22 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 23 | -------------------------------------------------------------------------------- /src/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/database/adapter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/adapter/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/database/adapter/adapter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Sequence, Any 3 | 4 | from sqlalchemy import delete, insert, select, CursorResult 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | 7 | from ..interfaces.adapter import AbstractDbAdapter 8 | from ..models.schemas import User, Service 9 | 10 | 11 | class DbAdapter(AbstractDbAdapter): 12 | def __init__(self, session: AsyncSession): 13 | self.session = session 14 | 15 | async def commit(self) -> None: 16 | return await self.session.commit() 17 | 18 | async def add_user( 19 | self, user_id: int, user_name: str, chat_id: int 20 | ) -> CursorResult[Any]: 21 | return await self.session.execute( 22 | insert(User).values( 23 | user_id=user_id, user_name=user_name, chat_id=chat_id 24 | ) 25 | ) 26 | 27 | async def create_subscription( 28 | self, title: str, months: int, reminder: datetime, service_fk: int 29 | ) -> CursorResult[Any]: 30 | return await self.session.execute( 31 | insert(Service).values( 32 | title=title, months=months, reminder=reminder, service_fk=service_fk, 33 | ) 34 | ) 35 | 36 | async def delete_subscription(self, service_id: int) -> CursorResult[Any]: 37 | return await self.session.execute( 38 | delete(Service).where(Service.service_id == service_id) 39 | ) 40 | 41 | async def get_user(self, user_id: int) -> None: 42 | return await self.session.get(User, user_id) 43 | 44 | async def get_service(self, service_id: int) -> None: 45 | return await self.session.scalar( 46 | select(Service).where(Service.service_id == service_id) 47 | ) 48 | 49 | async def get_services(self, user_id: int) -> Sequence[Service]: 50 | return ( 51 | await self.session.scalars( 52 | select(Service).where(Service.service_fk == user_id) 53 | ) 54 | ).all() 55 | 56 | async def get_quantity_subs(self, user_id: int) -> None: 57 | return await self.session.scalar( 58 | select(User.count_subs).where(User.user_id == user_id) 59 | ) 60 | 61 | async def get_language(self, user_id: int) -> None: 62 | return await self.session.scalar( 63 | select(User.language).where(User.user_id == user_id) 64 | ) 65 | 66 | async def update_language(self, user_id: int, language: str) -> User: 67 | return await self.session.merge( 68 | User(user_id=user_id, language=language) 69 | ) 70 | 71 | async def edit_sub_title( 72 | self, service_id: int, title: str 73 | ) -> Service: 74 | return await self.session.merge( 75 | Service(service_id=service_id, title=title) 76 | ) 77 | 78 | async def edit_sub_months( 79 | self, service_id: int, months: int 80 | ) -> Service: 81 | return await self.session.merge( 82 | Service(service_id=service_id, months=months) 83 | ) 84 | 85 | async def edit_sub_date( 86 | self, service_id: int, reminder: datetime 87 | ) -> Service: 88 | return await self.session.merge( 89 | Service(service_id=service_id, reminder=reminder) 90 | ) 91 | 92 | async def increment_quantity(self, user_id: int) -> User: 93 | return await self.session.merge( 94 | User(user_id=user_id, count_subs=User.count_subs + 1) 95 | ) 96 | 97 | async def decrement_quantity(self, user_id: int) -> User: 98 | return await self.session.merge( 99 | User(user_id=user_id, count_subs=User.count_subs - 1) 100 | ) 101 | -------------------------------------------------------------------------------- /src/infrastructure/database/interfaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/interfaces/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/database/interfaces/adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | from datetime import datetime 3 | 4 | 5 | class AbstractDbAdapter(Protocol): 6 | async def commit(self): 7 | raise NotImplementedError 8 | 9 | async def add_user( 10 | self, user_id: int, user_name: str, chat_id: int 11 | ): 12 | raise NotImplementedError 13 | 14 | async def create_subscription( 15 | self, title: str, months: int, reminder: datetime, service_fk: int 16 | ): 17 | raise NotImplementedError 18 | 19 | async def delete_subscription(self, service_id: int): 20 | raise NotImplementedError 21 | 22 | async def get_user(self, user_id: int): 23 | raise NotImplementedError 24 | 25 | async def get_service(self, service_id: int): 26 | raise NotImplementedError 27 | 28 | async def get_services(self, user_id: int): 29 | raise NotImplementedError 30 | 31 | async def get_quantity_subs(self, user_id: int): 32 | raise NotImplementedError 33 | 34 | async def get_language(self, user_id: int): 35 | raise NotImplementedError 36 | 37 | async def update_language(self, user_id: int, language: str): 38 | raise NotImplementedError 39 | 40 | async def edit_sub_title(self, service_id: int, title: str): 41 | raise NotImplementedError 42 | 43 | async def edit_sub_months(self, service_id: int, months: int): 44 | raise NotImplementedError 45 | 46 | async def edit_sub_date(self, service_id: int, reminder: datetime): 47 | raise NotImplementedError 48 | 49 | async def increment_quantity(self, user_id: int): 50 | raise NotImplementedError 51 | 52 | async def decrement_quantity(self, user_id: int): 53 | raise NotImplementedError 54 | 55 | # @abstractmethod 56 | # async def get_services_by_date(self): 57 | # raise NotImplementedError 58 | # 59 | # @abstractmethod 60 | # async def delete_services_by_ids(self, services_ids: list): 61 | # raise NotImplementedError 62 | -------------------------------------------------------------------------------- /src/infrastructure/database/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/database/models/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/database/models/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file creates a base class to define a declarative class 3 | """ 4 | 5 | from sqlalchemy import MetaData 6 | from sqlalchemy.orm import DeclarativeBase, registry 7 | 8 | convention = { 9 | 'ix': 'ix_%(column_0_label)s', 10 | 'uq': 'uq_%(table_name)s_%(column_0_N_name)s', 11 | 'ck': 'ck_%(table_name)s_%(constraint_name)s', 12 | 'fk': 'fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s', 13 | 'pk': 'pk_%(table_name)s', 14 | } 15 | 16 | mapper_registry = registry(metadata=MetaData(naming_convention=convention)) 17 | 18 | 19 | class BaseModel(DeclarativeBase): 20 | registry = mapper_registry 21 | metadata = mapper_registry.metadata 22 | -------------------------------------------------------------------------------- /src/infrastructure/database/models/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import ( 4 | BigInteger, 5 | DateTime, 6 | ForeignKey, 7 | Integer, 8 | SmallInteger, 9 | String, 10 | ) 11 | from sqlalchemy.orm import ( 12 | Mapped, 13 | mapped_column, 14 | relationship 15 | ) 16 | 17 | from .base import BaseModel 18 | 19 | 20 | class User(BaseModel): 21 | __tablename__ = 'users' 22 | 23 | user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 24 | user_name: Mapped[str] = mapped_column(String(length=32)) 25 | chat_id: Mapped[int] = mapped_column(BigInteger) 26 | language: Mapped[str] = mapped_column(String(length=5), default='ru_RU') 27 | count_subs: Mapped[int] = mapped_column(SmallInteger, default=0) 28 | 29 | def __repr__(self) -> str: 30 | return f'User:{self.user_id}:{self.user_name}' 31 | 32 | 33 | class Service(BaseModel): 34 | __tablename__ = 'services' 35 | 36 | service_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 37 | service_fk: Mapped[int] = mapped_column(BigInteger, ForeignKey('users.user_id')) 38 | title: Mapped[str] = mapped_column(String(length=30)) 39 | months: Mapped[int] = mapped_column(SmallInteger) 40 | reminder: Mapped[datetime] = mapped_column(DateTime) 41 | 42 | user = relationship(argument='User', lazy='joined', innerjoin=True) 43 | 44 | def __repr__(self) -> str: 45 | return f'Service:{self.service_id}:{self.title}:{self.reminder}' 46 | 47 | 48 | class CommonService(BaseModel): 49 | __tablename__ = 'common_services' 50 | 51 | title: Mapped[str] = mapped_column(String(length=255), primary_key=True) 52 | 53 | def __repr__(self) -> str: 54 | return f'CommonService:{self.title}' 55 | -------------------------------------------------------------------------------- /src/infrastructure/scheduler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/scheduler/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/scheduler/tasks.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import ormsgpack 4 | import uuid6 5 | import zstd 6 | from sqlalchemy import delete, func, select 7 | from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession 8 | from sqlalchemy.orm import joinedload 9 | from taskiq import Context, TaskiqDepends 10 | 11 | from ..database.models.schemas import Service, User 12 | from ..scheduler.tkq import broker 13 | 14 | 15 | @broker.task( 16 | task_name='base_polling', 17 | schedule=[ 18 | { 19 | "cron": "*/1 * * * *", 20 | "cron_offset": "Europe/Moscow" 21 | }, 22 | { 23 | "cron": "0 12 * * *", 24 | "cron_offset": "Europe/Moscow" 25 | }, 26 | { 27 | "cron": "0 16 * * *", 28 | "cron_offset": "Europe/Moscow" 29 | }, 30 | ], 31 | ) 32 | async def base_polling_task(context: Context = TaskiqDepends()) -> None: 33 | nats_connect = context.state.nats 34 | async_engine = context.state.database 35 | 36 | jetstream = nats_connect.jetstream() 37 | async_session_maker: async_sessionmaker = async_sessionmaker( 38 | bind=async_engine, 39 | class_=AsyncSession, 40 | autoflush=True, 41 | expire_on_commit=True, # when you commit, load new object from database 42 | ) 43 | 44 | async with async_session_maker() as session: 45 | request = await session.scalars( 46 | select(Service) 47 | .options( 48 | joinedload(Service.user) 49 | ) 50 | .where( 51 | func.date(Service.reminder) == datetime.datetime.utcnow().date() 52 | ) 53 | ) 54 | services = request.all() 55 | identifiers = list() 56 | 57 | for service in services: 58 | await jetstream.publish( 59 | subject='service_notify.message', 60 | payload=zstd.compress( 61 | ormsgpack.packb( 62 | { 63 | 'chat_id': service.user.chat_id, 64 | 'language': service.user.language, 65 | 'service': service.title, 66 | 'months': service.months 67 | } 68 | ) 69 | ), 70 | headers={ 71 | 'Nats-Msg-Id': uuid6.uuid8().hex, # uuid8, because uniqueness guarantee 72 | }, 73 | ) 74 | await session.merge( 75 | User( 76 | user_id=service.user.user_id, 77 | count_subs=User.count_subs - 1, 78 | ) 79 | ) 80 | identifiers.append(service.service_id) 81 | 82 | await session.execute( 83 | delete(Service).where(Service.service_id.in_(identifiers)) 84 | ) 85 | await session.commit() 86 | -------------------------------------------------------------------------------- /src/infrastructure/scheduler/tkq.py: -------------------------------------------------------------------------------- 1 | import nats 2 | from loguru import logger 3 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine 4 | from taskiq import TaskiqEvents, TaskiqScheduler, TaskiqState 5 | from taskiq.schedule_sources import LabelScheduleSource 6 | from taskiq_nats import NatsBroker 7 | 8 | from src.core.utils.builders import create_nats_url, create_postgres_url 9 | 10 | broker = NatsBroker( 11 | servers=[ 12 | create_nats_url().human_repr(), 13 | ], 14 | queue='send_service', 15 | ) 16 | scheduler = TaskiqScheduler( 17 | broker=broker, 18 | sources=[ 19 | LabelScheduleSource(broker=broker), 20 | ], 21 | ) 22 | 23 | 24 | @broker.on_event(TaskiqEvents.WORKER_STARTUP) 25 | async def startup(state: TaskiqState) -> None: 26 | logger.info('Taskiq Launching') 27 | 28 | nats_connect = await nats.connect( 29 | servers=[ 30 | create_nats_url().human_repr() 31 | ] 32 | ) 33 | async_engine: AsyncEngine = create_async_engine( 34 | url=create_postgres_url().human_repr(), 35 | pool_pre_ping=True, 36 | echo=False, 37 | ) 38 | 39 | state.nats = nats_connect 40 | state.database = async_engine 41 | 42 | 43 | @broker.on_event(TaskiqEvents.WORKER_SHUTDOWN) 44 | async def shutdown(state: TaskiqState) -> None: 45 | logger.warning('Taskiq Shutdown') 46 | 47 | await state.nats.drain() 48 | await state.database.dispose() 49 | -------------------------------------------------------------------------------- /src/infrastructure/stream/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/infrastructure/stream/__init__.py -------------------------------------------------------------------------------- /src/infrastructure/stream/worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import ormsgpack 4 | import zstd 5 | from aiogram import Bot 6 | from aiogram.exceptions import TelegramRetryAfter, TelegramForbiddenError 7 | from loguru import logger 8 | from nats.js import JetStreamContext 9 | 10 | from src.presentation.tgbot.keyboards.inline import get_extension_menu 11 | 12 | 13 | async def nats_polling( 14 | bot: Bot, i18n_middleware, jetstream: JetStreamContext 15 | ) -> None: 16 | subscribe = await jetstream.subscribe( 17 | subject='service_notify.message', 18 | stream='service_notify', 19 | durable='get_message', 20 | manual_ack=True, 21 | ) 22 | 23 | async for message in subscribe.messages: 24 | try: 25 | data = ormsgpack.unpackb(zstd.decompress(message.data)) 26 | 27 | chat_id = data['chat_id'] 28 | language = data['language'] 29 | service = data['service'] 30 | months = data['months'] 31 | 32 | l10n = i18n_middleware.l10ns[language] 33 | 34 | await bot.send_message( 35 | chat_id=chat_id, 36 | text=l10n.format_value( 37 | 'notification-message', { 38 | 'service': service 39 | } 40 | ), 41 | reply_markup=get_extension_menu( 42 | text=l10n.format_value('renew'), 43 | service=service, 44 | months=months, 45 | ), 46 | ) 47 | await message.ack() 48 | 49 | except TimeoutError: 50 | pass 51 | except TelegramRetryAfter as ex: 52 | logger.warning(f'Limit exceeded, continue in: {ex.retry_after}') 53 | await asyncio.sleep(float(ex.retry_after)) 54 | continue 55 | except TelegramForbiddenError: 56 | logger.info('User blocked Bot') 57 | await message.ack() 58 | continue 59 | except BaseException as ex: 60 | logger.error(f'Unexpected error: {ex}') 61 | continue 62 | -------------------------------------------------------------------------------- /src/presentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/__init__.py -------------------------------------------------------------------------------- /src/presentation/locales/de_DE/main.ftl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/locales/de_DE/main.ftl -------------------------------------------------------------------------------- /src/presentation/locales/en_GB/main.ftl: -------------------------------------------------------------------------------- 1 | settings = ⚙️ Settings 2 | support = 🆘 Support 3 | my-subscriptions = 🗂️ My Subscriptions 4 | administrator = 👨‍💻 Administrator 5 | back = ↩️ Back 6 | 7 | add = Add 8 | delete = Delete 9 | change = Change 10 | 11 | title = Title 12 | months = Months 13 | date = Date 14 | renew = Renew 15 | 16 | select-lang = 🌍 Select the language in which the bot will communicate: 17 | 18 | catalog-add = 🗂️ Subscriptions add catalog: 19 | 20 | { $subs } 21 | 22 | catalog-remove = 🗂️ Subscription removal catalog: 23 | 24 | { $message } 25 | 26 | catalog-edit = 🗂️ Subscription editing catalog: 27 | 28 | { $message } 29 | 30 | conformation = Are you sure you want to delete the subscription? 31 | 32 | faq = ❓ FAQ 33 | 34 | 1. What is this bot for? 35 | — The bot was created to remind the user when his subscription to any service expires. 36 | 37 | 2. What services can be added? 38 | — It doesn't matter where you subscribed, you can add any services. 39 | 40 | 3. How to add a service? 41 | — Go to the My Subscriptions section and click the Add button. Fill in the data strictly following the instructions: first enter the name, next step enter the quantity. months (number), then select on the calendar when remind you to write off. Confirm that the subscription is correct and the subscription will be added. 42 | 43 | add-service-title = What is the name of the service that you subscribed to? 44 | 45 | Example: Tinkoff Premium 46 | add-service-months = How many months will the subscription last? 47 | 48 | Example: 12 (mon.) 49 | 50 | add-calendar-date = What date to notify about the next write-off? 51 | 52 | check-form = 📩 Check correctness of the entered data: 53 | 54 | Service: { $service } 55 | Duration: { $months } (mon.) 56 | Notify: { $reminder } 57 | 58 | start-menu = Subscriptions Controller — is the best way to control your subscriptions 59 | 60 | 📣 Required add your subscriptions to our service to receive notifications about the next charge 61 | 62 | nothing-delete = 🤷‍♂️ It seems, there is nothing to delete here... 63 | 64 | nothing-output = 🤷‍♂️ It seems that we haven't found anything... 65 | 66 | set-for-delete = Select the subscription that you want to delete: 67 | 68 | set-for-edit = Select the subscription that you want to change: 69 | 70 | error-subs-limit = 🚫 Error: Subscription limit reached 71 | 72 | error-len-limit = 🚫 Error: Character limit reached 73 | 74 | error-unsupported-char = 🚫 Error: Invalid characters entered 75 | 76 | error-range-reached = 🚫 Error: Value range reached 77 | 78 | approve-sub-add = ✅ Approved: Data written successfully 79 | 80 | error-sub-add = ❎ Rejected: Data not recorded 81 | 82 | approve-sub-delete = ✅ Approved: Subscription successfully deleted 83 | 84 | reject-sub-delete = ❎ Rejected: Subscription not deleted 85 | 86 | approve-sub-edit = ✅ Approved: Subscription changed successfully 87 | 88 | reject-sub-edit = ❎ Rejected: Subscription not changed 89 | 90 | notification-message = 🔔 Notification 91 | We remind you that your { $service } subscription will soon end! 92 | 93 | select-parameters = Select the parameter that you want to change: 94 | 95 | edit-form = Select the subscription that you want to change: 96 | 97 | check-title-form = 📩 Check correctness of data changes: 98 | 99 | { $service_old_title }{ $service_new_title } 100 | 101 | check-months-form = 📩 Check correctness of data changes: 102 | 103 | { $service_old_months } (mon.){ $service_new_months } (mon.) 104 | 105 | check-reminder-form = 📩 Check correctness of data changes: 106 | 107 | { $service_old_reminder }{ $service_new_reminder } -------------------------------------------------------------------------------- /src/presentation/locales/ru_RU/main.ftl: -------------------------------------------------------------------------------- 1 | settings = ⚙️ Настройки 2 | support = 🆘 Поддержка 3 | my-subscriptions = 🗂️ Мои подписки 4 | administrator = 👨‍💻 Администратор 5 | back = ↩️ Назад 6 | 7 | add = Добавить 8 | delete = Удалить 9 | change = Изменить 10 | 11 | title = Название 12 | months = Месяцы 13 | date = Дату 14 | renew = Продлить 15 | 16 | start-menu = Subscriptions Controllerлучший способ контролировать свои подписки 17 | 18 | 📣 Обязательно добавляйте свои подписки в наш сервис, чтобы получать уведомления о ближайшем списании 19 | 20 | select-lang = 🌍 Выберите язык, на котором будет общаться бот: 21 | 22 | faq = ❓ ЧаВо 23 | 24 | 1. Для чего этот бот? 25 | — Бот создан, с целью напомнить пользователю, когда истечет его подписка в каком-либо сервисе. 26 | 27 | 2. Какие сервисы можно добавлять? 28 | — Неважно где вы оформили подписку, можно добавлять любые сервисы. 29 | 30 | 3. Как добавить сервис? 31 | — Перейдите в раздел Мои подписки и нажмите кнопку Добавить. Заполняйте данные, строго следуя инструкциям: сначала введите название, следующим шагом введите кол-во.бли месяцев (число), затем выберите на календаре, когда напомнить о списании. Подтвердите правильность, и подписка будет добавлена. 32 | 33 | 34 | catalog-add = 🗂️ Каталог добавленных подписок: 35 | 36 | { $subs } 37 | 38 | catalog-remove = 🗂️ Каталог удаления подписок: 39 | 40 | { $message } 41 | 42 | catalog-edit = 🗂️ Каталог изменения подписок: 43 | 44 | { $message } 45 | 46 | conformation = Вы действительно хотите удалить подписку? 47 | 48 | add-service-title = Как называется сервис на который Вы подписались? 49 | 50 | Пример: Tinkoff Premium 51 | 52 | add-service-months = Сколько месяцев будет действовать подписка? 53 | 54 | Пример: 12 (мес.) 55 | 56 | add-calendar-date = В какую дату оповестить о ближайшем списании? 57 | 58 | check-form = 📩 Проверьте правильность введённых данных: 59 | 60 | Сервис: { $service } 61 | Длительность: { $months } (мес.) 62 | Оповестить: { $reminder } 63 | 64 | nothing-delete = 🤷‍♂️ Кажется, здесь нечего удалять... 65 | 66 | nothing-output = 🤷‍♂️ Кажется, мы ничего не нашли... 67 | 68 | set-for-delete = Выберите подписку, которую хотите удалить: 69 | 70 | set-for-edit = Выберите подписку, которую хотите изменить: 71 | 72 | error-subs-limit = 🚫 Ошибка: Достигнут лимит подписок 73 | 74 | error-len-limit = 🚫 Ошибка: Достигнут лимит символов 75 | 76 | error-unsupported-char = 🚫 Ошибка: Введены недопустимые символы 77 | 78 | error-range-reached = 🚫 Ошибка: Достигнут диапазон значений 79 | 80 | approve-sub-add = ✅ Одобрено: Данные успешно записаны 81 | 82 | error-sub-add = ❎ Отклонено: Данные не записаны 83 | 84 | approve-sub-delete = ✅ Одобрено: Подписка успешно удалена 85 | 86 | reject-sub-delete = ❎ Отклонено: Подписка не удалена 87 | 88 | approve-sub-edit = ✅ Одобрено: Подписка успешно изменена 89 | 90 | reject-sub-edit = ❎ Отклонено: Подписка не изменена 91 | 92 | notification-message = 🔔 Уведомление 93 | Напоминаем Вам, что ваша подписка { $service } скоро закончится! 94 | 95 | select-parameters = Выберите параметр, который хотите изменить: 96 | 97 | edit-form = Выберите подписку, которую хотите изменить: 98 | 99 | check-title-form = 📩 Проверьте правильность изменения данных: 100 | 101 | { $service_old_title }{ $service_new_title } 102 | 103 | check-months-form = 📩 Проверьте правильность изменения данных: 104 | 105 | { $service_old_months } (мес.){ $service_new_months } (мес.) 106 | 107 | check-reminder-form = 📩 Проверьте правильность изменения данных: 108 | 109 | { $service_old_reminder }{ $service_new_reminder } -------------------------------------------------------------------------------- /src/presentation/tgbot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The main file responsible for launching the bot 3 | """ 4 | 5 | import asyncio 6 | import logging 7 | 8 | import nats 9 | from aiogram import Bot, Dispatcher 10 | from aiogram.enums import ParseMode 11 | from aiogram.filters import ExceptionTypeFilter 12 | from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisStorage 13 | from aiogram_dialog import setup_dialogs 14 | from aiogram_dialog.api.exceptions import UnknownIntent, UnknownState 15 | from loguru import logger 16 | from nats.aio.client import Client 17 | from nats.js import JetStreamContext 18 | from sqlalchemy.ext.asyncio import ( 19 | async_sessionmaker, 20 | AsyncEngine, 21 | AsyncSession, 22 | create_async_engine, 23 | ) 24 | 25 | from src.core.config.parser import settings 26 | from src.core.utils.builders import create_postgres_url, create_nats_url, create_redis_url 27 | from src.core.utils.logging import InterceptHandler 28 | from src.infrastructure.stream.worker import nats_polling 29 | from src.presentation.tgbot.dialogs.create_menu.dialog import create_menu 30 | from src.presentation.tgbot.dialogs.delete_menu.dialog import delete_menu 31 | from src.presentation.tgbot.dialogs.edit_menu.dialog import edit_menu 32 | from src.presentation.tgbot.dialogs.main_menu.dialog import main_menu 33 | from src.presentation.tgbot.handlers import client, errors 34 | from src.presentation.tgbot.middlewares.database import DbSessionMiddleware 35 | from src.presentation.tgbot.middlewares.i18n import I18nMiddleware, make_i18n_middleware 36 | 37 | 38 | async def _main() -> None: 39 | """ 40 | The main function responsible for launching the bot 41 | :return: 42 | """ 43 | logging.basicConfig( 44 | handlers=[ 45 | InterceptHandler() 46 | ], 47 | level='DEBUG', 48 | force=True 49 | ) 50 | logger.add( 51 | sink='../../../debug.log', 52 | format='{time} | {level} | {message}', 53 | level='DEBUG', 54 | enqueue=True, 55 | colorize=True, 56 | encoding='utf-8', 57 | rotation='10 MB', 58 | compression='zip' 59 | ) 60 | 61 | async_engine: AsyncEngine = create_async_engine( 62 | url=create_postgres_url().human_repr(), 63 | pool_pre_ping=True, 64 | echo=False, 65 | ) 66 | async_session_maker: async_sessionmaker = async_sessionmaker( 67 | bind=async_engine, 68 | class_=AsyncSession, 69 | autoflush=True, 70 | expire_on_commit=True, 71 | ) 72 | 73 | nats_client: Client = await nats.connect( 74 | servers=[ 75 | create_nats_url().human_repr() 76 | ], 77 | ) 78 | jetstream: JetStreamContext = nats_client.jetstream() 79 | 80 | storage: RedisStorage = RedisStorage.from_url( 81 | url=create_redis_url().human_repr(), 82 | key_builder=DefaultKeyBuilder( 83 | with_destiny=True, 84 | with_bot_id=True 85 | ), 86 | ) 87 | bot: Bot = Bot( 88 | token=settings['API_TOKEN'], parse_mode=ParseMode.HTML 89 | ) 90 | disp: Dispatcher = Dispatcher( 91 | storage=storage, events_isolation=storage.create_isolation() 92 | ) 93 | 94 | i18n_middleware: I18nMiddleware = make_i18n_middleware() 95 | 96 | disp.message.middleware(i18n_middleware) 97 | disp.callback_query.middleware(i18n_middleware) 98 | disp.update.outer_middleware( 99 | DbSessionMiddleware(session_maker=async_session_maker) 100 | ) 101 | 102 | disp.include_router(client.router) 103 | disp.include_routers( 104 | main_menu, 105 | create_menu, 106 | delete_menu, 107 | edit_menu, 108 | ) 109 | disp.errors.register(errors.on_unknown_intent, ExceptionTypeFilter(UnknownIntent)) 110 | disp.errors.register(errors.on_unknown_state, ExceptionTypeFilter(UnknownState)) 111 | 112 | setup_dialogs(disp) 113 | 114 | logger.info('Bot Launching') 115 | 116 | try: 117 | await jetstream.add_stream( 118 | name='service_notify', 119 | subjects=['service_notify.*'], 120 | retention='interest', 121 | storage='file' 122 | ) 123 | await bot.delete_webhook(drop_pending_updates=True) 124 | await asyncio.gather( 125 | nats_polling( 126 | bot=bot, 127 | i18n_middleware=i18n_middleware, 128 | jetstream=jetstream 129 | ), 130 | disp.start_polling(bot), 131 | ) 132 | finally: 133 | await storage.close() 134 | await bot.session.close() 135 | await async_engine.dispose() 136 | await nats_client.drain() 137 | await logger.complete() 138 | 139 | 140 | if __name__ == '__main__': 141 | try: 142 | asyncio.run(_main()) 143 | except (SystemExit, KeyboardInterrupt): 144 | logger.warning('Bot Shutdown') 145 | -------------------------------------------------------------------------------- /src/presentation/tgbot/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | LANGUAGES: Final[list] = [' 🇷🇺 Русский', ' 🇬🇧 English'] 4 | 5 | LOCALES: Final[list] = ['ru_RU', 'en_GB'] 6 | DEFAULT_LOCALE: Final[str] = 'ru_RU' 7 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/create_menu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/create_menu/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/create_menu/dialog.py: -------------------------------------------------------------------------------- 1 | from aiogram.enums import ContentType 2 | from aiogram_dialog import Dialog, Window 3 | from aiogram_dialog.widgets.input import MessageInput 4 | from aiogram_dialog.widgets.kbd import ( 5 | Button, 6 | Group, 7 | Row 8 | ) 9 | from aiogram_dialog.widgets.text import Const 10 | 11 | from src.presentation.tgbot.states.user import CreateMenu 12 | from .handlers import ( 13 | add_title_handler, 14 | add_months_handler, 15 | on_click_confirm_data, 16 | on_click_reject_data, 17 | on_click_select_date 18 | ) 19 | from ..extras.calendar import CustomCalendar 20 | from ..extras.i18n_format import I18NFormat 21 | from ..main_menu.getters import get_input_service_data 22 | from ..main_menu.handler import on_click_get_subs_menu 23 | 24 | create_menu = Dialog( 25 | Window( 26 | I18NFormat('add-service-title'), 27 | MessageInput(func=add_title_handler, content_types=ContentType.TEXT), 28 | Button( 29 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu 30 | ), 31 | state=CreateMenu.TITLE, 32 | ), 33 | Window( 34 | I18NFormat('add-service-months'), 35 | MessageInput(func=add_months_handler, content_types=ContentType.TEXT), 36 | Button( 37 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu 38 | ), 39 | state=CreateMenu.MONTHS, 40 | ), 41 | Window( 42 | I18NFormat('add-calendar-date'), 43 | Group( 44 | CustomCalendar( 45 | id='select_date_id', on_click=on_click_select_date 46 | ), 47 | Button( 48 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu, 49 | ), 50 | ), 51 | state=CreateMenu.REMINDER, 52 | ), 53 | Window( 54 | I18NFormat('check-form'), 55 | Row( 56 | Button( 57 | Const('✅'), id='confirm_add_id', on_click=on_click_confirm_data, 58 | ), 59 | Button( 60 | Const('❎'), id='reject_add_id', on_click=on_click_reject_data 61 | ), 62 | ), 63 | state=CreateMenu.CHECK_ADD, 64 | ), 65 | getter=get_input_service_data, 66 | ) 67 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/create_menu/getters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from aiogram_dialog import DialogManager 4 | 5 | 6 | async def get_subs_for_output(dialog_manager: DialogManager, **kwargs) -> dict[str, str]: 7 | l10n = dialog_manager.middleware_data['l10n'] 8 | session = dialog_manager.middleware_data['session'] 9 | 10 | services = await session.get_services(user_id=dialog_manager.event.from_user.id) 11 | 12 | if not services: 13 | return {'subs': l10n.format_value('nothing-output')} 14 | 15 | subs = [ 16 | f'{count}. {item.title} — {datetime.date(item.reminder)}\n' 17 | for count, item in enumerate(iterable=services, start=1) 18 | ] 19 | return {'subs': ''.join(subs)} 20 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/create_menu/handlers.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | import markupsafe 4 | from aiogram.types import CallbackQuery, Message 5 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode 6 | from aiogram_dialog.widgets.kbd import Button 7 | 8 | from src.presentation.tgbot.states.user import CreateMenu, MainMenu 9 | 10 | 11 | async def add_title_handler( 12 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager 13 | ) -> Message: 14 | l10n = dialog_manager.middleware_data['l10n'] 15 | 16 | if len(message.text) > 30: 17 | return await message.answer(l10n.format_value('error-len-limit')) 18 | 19 | dialog_manager.dialog_data['service'] = markupsafe.escape(message.text) 20 | await dialog_manager.switch_to(state=CreateMenu.MONTHS) 21 | 22 | 23 | async def add_months_handler( 24 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager 25 | ) -> Message: 26 | l10n = dialog_manager.middleware_data['l10n'] 27 | 28 | try: 29 | value = int(message.text) 30 | except ValueError: 31 | return await message.answer( 32 | l10n.format_value('error-unsupported-char') 33 | ) 34 | 35 | if value not in range(1, 12 + 1): 36 | return await message.answer(l10n.format_value('error-range-reached')) 37 | 38 | dialog_manager.dialog_data['months'] = int(message.text) 39 | await dialog_manager.switch_to(state=CreateMenu.REMINDER) 40 | 41 | 42 | async def on_click_select_date( 43 | callback: CallbackQuery, 44 | button: Button, 45 | dialog_manager: DialogManager, 46 | selected_date: date, 47 | ) -> None: 48 | dialog_manager.dialog_data['reminder'] = selected_date.isoformat() 49 | await dialog_manager.switch_to(state=CreateMenu.CHECK_ADD) 50 | 51 | 52 | async def on_click_confirm_data( 53 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 54 | ) -> None: 55 | l10n = dialog_manager.middleware_data['l10n'] 56 | session = dialog_manager.middleware_data['session'] 57 | 58 | await session.create_subscription( 59 | title=dialog_manager.dialog_data['service'], 60 | months=dialog_manager.dialog_data['months'], 61 | reminder=datetime.fromisoformat(dialog_manager.dialog_data['reminder']), 62 | service_fk=callback.from_user.id, 63 | ) 64 | await session.increment_quantity(user_id=dialog_manager.event.from_user.id) 65 | await session.commit() 66 | 67 | await callback.message.edit_text(l10n.format_value('approve-sub-add')) 68 | await dialog_manager.done() 69 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK) 70 | 71 | 72 | async def on_click_reject_data( 73 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 74 | ) -> None: 75 | l10n = dialog_manager.middleware_data['l10n'] 76 | 77 | await callback.message.edit_text(l10n.format_value('error-sub-add')) 78 | await dialog_manager.done() 79 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK) 80 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/delete_menu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/delete_menu/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/delete_menu/dialog.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram_dialog import Dialog, Window 4 | from aiogram_dialog.widgets.kbd import ( 5 | Button, 6 | Column, 7 | Group, 8 | Row, 9 | Select 10 | ) 11 | from aiogram_dialog.widgets.text import Const, Format 12 | 13 | from src.presentation.tgbot.states.user import DeleteMenu 14 | from .getters import get_subs_for_delete 15 | from .handlers import on_click_sub_not_delete, on_click_sub_selected 16 | from ..extras.i18n_format import I18NFormat 17 | from ..main_menu.handler import on_click_get_subs_menu, on_click_sub_delete 18 | 19 | delete_menu = Dialog( 20 | Window( 21 | I18NFormat('catalog-remove'), 22 | Group( 23 | Column( 24 | Select( 25 | Format('{item[1]} — {item[2]}'), 26 | id='delete_id', 27 | item_id_getter=itemgetter(0), 28 | items='subs', 29 | on_click=on_click_sub_selected, 30 | type_factory=int, 31 | ), 32 | ), 33 | Button( 34 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu, 35 | ), 36 | ), 37 | getter=get_subs_for_delete, 38 | state=DeleteMenu.DELETE, 39 | ), 40 | Window( 41 | I18NFormat('conformation'), 42 | Row( 43 | Button( 44 | Const('✅'), id='confirm_delete_id', on_click=on_click_sub_delete, 45 | ), 46 | Button( 47 | Const('❎'), id='reject_delete_id', on_click=on_click_sub_not_delete, 48 | ), 49 | ), 50 | state=DeleteMenu.CHECK_DELETE, 51 | ), 52 | ) 53 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/delete_menu/getters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | 4 | from aiogram_dialog import DialogManager 5 | 6 | 7 | async def get_subs_for_delete(dialog_manager: DialogManager, **kwargs) -> dict[str, list[Any] | Any]: 8 | l10n = dialog_manager.middleware_data['l10n'] 9 | session = dialog_manager.middleware_data['session'] 10 | 11 | services = await session.get_services( 12 | user_id=dialog_manager.event.from_user.id 13 | ) 14 | 15 | if not services: 16 | return {'message': l10n.format_value('nothing-output'), 'subs': []} 17 | 18 | subs = [ 19 | (item.service_id, item.title, datetime.date(item.reminder).isoformat()) 20 | for item in services 21 | ] 22 | return {'message': l10n.format_value('set-for-delete'), 'subs': subs} 23 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/delete_menu/handlers.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery 2 | from aiogram_dialog import DialogManager, StartMode 3 | from aiogram_dialog.widgets.kbd import Button 4 | 5 | from src.presentation.tgbot.states.user import DeleteMenu 6 | 7 | 8 | async def on_click_get_delete_menu( 9 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager, 10 | ) -> None: 11 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK) 12 | 13 | 14 | async def on_click_sub_not_delete( 15 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 16 | ) -> None: 17 | l10n = dialog_manager.middleware_data['l10n'] 18 | 19 | await callback.message.edit_text(l10n.format_value('reject-sub-delete')) 20 | await dialog_manager.done() 21 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK) 22 | 23 | 24 | async def on_click_sub_selected( 25 | callback: CallbackQuery, 26 | button: Button, 27 | dialog_manager: DialogManager, 28 | item_id: int, 29 | ) -> None: 30 | await dialog_manager.start(state=DeleteMenu.CHECK_DELETE, mode=StartMode.RESET_STACK) 31 | dialog_manager.dialog_data['service_id'] = item_id 32 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/edit_menu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/edit_menu/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/edit_menu/dialog.py: -------------------------------------------------------------------------------- 1 | from operator import itemgetter 2 | 3 | from aiogram.enums import ContentType 4 | from aiogram_dialog import Dialog, Window 5 | from aiogram_dialog.widgets.input import MessageInput 6 | from aiogram_dialog.widgets.kbd import ( 7 | Button, 8 | Column, 9 | Group, 10 | Row, 11 | Select, 12 | SwitchTo, 13 | ) 14 | from aiogram_dialog.widgets.text import Const, Format 15 | 16 | from src.presentation.tgbot.states.user import EditMenu 17 | from ..edit_menu.getters import ( 18 | get_service_months_data, 19 | get_service_reminder_data, 20 | get_service_title_data, 21 | get_subs_for_edit, 22 | ) 23 | from ..edit_menu.handlers import ( 24 | approve_edit_menu, 25 | edit_months_handler, 26 | edit_reminder_handler, 27 | edit_title_handler, 28 | on_click_edit_date, 29 | on_click_edit_months, 30 | on_click_edit_title, 31 | on_click_set_parameters, 32 | reject_edit_menu, 33 | ) 34 | from ..extras.calendar import CustomCalendar 35 | from ..extras.i18n_format import I18NFormat 36 | from ..main_menu.handler import on_click_get_subs_menu 37 | 38 | edit_menu = Dialog( 39 | Window( 40 | I18NFormat('catalog-edit'), 41 | Column( 42 | Select( 43 | Format('{item[1]} — {item[2]}'), 44 | id='edit_id', 45 | item_id_getter=itemgetter(0), 46 | items='subs', 47 | on_click=on_click_set_parameters, 48 | type_factory=int, 49 | ), 50 | Button( 51 | I18NFormat('back'), id='back_id', on_click=on_click_get_subs_menu, 52 | ), 53 | ), 54 | getter=get_subs_for_edit, 55 | state=EditMenu.EDIT, 56 | ), 57 | Window( 58 | I18NFormat('select-parameters'), 59 | Group( 60 | Row( 61 | Button( 62 | I18NFormat('title'), id='title_id', on_click=on_click_edit_title, 63 | ), 64 | Button( 65 | I18NFormat('months'), id='months_id', on_click=on_click_edit_months, 66 | ), 67 | Button( 68 | I18NFormat('date'), id='date_id', on_click=on_click_edit_date, 69 | ), 70 | ), 71 | SwitchTo( 72 | I18NFormat('back'), id='back_id', state=EditMenu.EDIT 73 | ), 74 | ), 75 | state=EditMenu.PARAMETERS, 76 | ), 77 | Window( 78 | I18NFormat('add-service-title'), 79 | MessageInput(func=edit_title_handler, content_types=ContentType.TEXT), 80 | SwitchTo( 81 | I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS 82 | ), 83 | state=EditMenu.TITLE, 84 | ), 85 | Window( 86 | I18NFormat('add-service-months'), 87 | MessageInput(func=edit_months_handler, content_types=ContentType.TEXT), 88 | SwitchTo(I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS), 89 | state=EditMenu.MONTHS, 90 | ), 91 | Window( 92 | I18NFormat('add-calendar-date'), 93 | Group( 94 | CustomCalendar( 95 | id='select_date_id', on_click=edit_reminder_handler 96 | ), 97 | SwitchTo( 98 | I18NFormat('back'), id='back_id', state=EditMenu.PARAMETERS 99 | ), 100 | ), 101 | state=EditMenu.REMINDER, 102 | ), 103 | Window( 104 | I18NFormat('check-title-form'), 105 | Row( 106 | Button( 107 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu 108 | ), 109 | Button( 110 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu 111 | ), 112 | ), 113 | getter=get_service_title_data, 114 | state=EditMenu.CHECK_TITLE_CHANGE, 115 | ), 116 | Window( 117 | I18NFormat('check-months-form'), 118 | Row( 119 | Button( 120 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu 121 | ), 122 | Button( 123 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu 124 | ), 125 | ), 126 | getter=get_service_months_data, 127 | state=EditMenu.CHECK_MONTHS_CHANGE, 128 | ), 129 | Window( 130 | I18NFormat('check-reminder-form'), 131 | Row( 132 | Button( 133 | Const('✅'), id='confirm_delete_id', on_click=approve_edit_menu 134 | ), 135 | Button( 136 | Const('❎'), id='reject_delete_id', on_click=reject_edit_menu 137 | ), 138 | ), 139 | getter=get_service_reminder_data, 140 | state=EditMenu.CHECK_REMINDER_CHANGE, 141 | ), 142 | ) 143 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/edit_menu/getters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | 4 | from aiogram_dialog import DialogManager 5 | 6 | 7 | async def get_subs_for_edit( 8 | dialog_manager: DialogManager, **kwargs 9 | ) -> dict[str, list[tuple[int, str, str]] | Any]: 10 | l10n = dialog_manager.middleware_data['l10n'] 11 | session = dialog_manager.middleware_data['session'] 12 | 13 | services = await session.get_services(user_id=dialog_manager.event.from_user.id) 14 | 15 | if not services: 16 | return { 17 | 'message': l10n.format_value('nothing-output'), 18 | 'subs': [] 19 | } 20 | 21 | subs = [ 22 | (item.service_id, item.title, datetime.date(item.reminder).isoformat()) 23 | for item in services 24 | ] 25 | return { 26 | 'message': l10n.format_value('set-for-edit'), 27 | 'subs': subs 28 | } 29 | 30 | 31 | async def get_service_title_data( 32 | dialog_manager: DialogManager, **kwargs 33 | ) -> dict[str, str]: 34 | return { 35 | 'service_new_title': dialog_manager.dialog_data.get('service_new_title'), 36 | 'service_old_title': dialog_manager.dialog_data.get('service_old_title'), 37 | } 38 | 39 | 40 | async def get_service_months_data( 41 | dialog_manager: DialogManager, **kwargs 42 | ) -> dict[str, str]: 43 | return { 44 | 'service_new_months': dialog_manager.dialog_data.get('service_new_months'), 45 | 'service_old_months': dialog_manager.dialog_data.get('service_old_months'), 46 | } 47 | 48 | 49 | async def get_service_reminder_data( 50 | dialog_manager: DialogManager, **kwargs 51 | ) -> dict[str, str]: 52 | return { 53 | 'service_new_reminder': dialog_manager.dialog_data.get('service_new_reminder'), 54 | 'service_old_reminder': dialog_manager.dialog_data.get('service_old_reminder'), 55 | } 56 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/edit_menu/handlers.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | 3 | import markupsafe 4 | from aiogram.types import CallbackQuery, Message 5 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode 6 | from aiogram_dialog.widgets.kbd import Button 7 | 8 | from src.presentation.tgbot.states.user import EditMenu, MainMenu 9 | 10 | 11 | async def on_click_get_edit_menu( 12 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 13 | ) -> None: 14 | await dialog_manager.start(state=EditMenu.EDIT, mode=StartMode.RESET_STACK) 15 | 16 | 17 | async def on_click_set_parameters( 18 | callback: CallbackQuery, 19 | button: Button, 20 | dialog_manager: DialogManager, 21 | item_id: int, 22 | ) -> None: 23 | dialog_manager.dialog_data['service_id'] = item_id 24 | await dialog_manager.switch_to(state=EditMenu.PARAMETERS) 25 | 26 | 27 | async def on_click_edit_title( 28 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager, 29 | ) -> None: 30 | await dialog_manager.switch_to(state=EditMenu.TITLE) 31 | 32 | 33 | async def on_click_edit_months( 34 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager, 35 | ) -> None: 36 | await dialog_manager.switch_to(state=EditMenu.MONTHS) 37 | 38 | 39 | async def on_click_edit_date( 40 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager, 41 | ) -> None: 42 | await dialog_manager.switch_to(state=EditMenu.REMINDER) 43 | 44 | 45 | async def edit_title_handler( 46 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager 47 | ) -> Message: 48 | l10n = dialog_manager.middleware_data['l10n'] 49 | session = dialog_manager.middleware_data['session'] 50 | 51 | if len(message.text) > 30: 52 | return await message.answer(l10n.format_value('error-len-limit')) 53 | 54 | service_id = dialog_manager.dialog_data['service_id'] 55 | service = await session.get_service(service_id=service_id) 56 | 57 | dialog_manager.dialog_data['service_old_title'] = service.title 58 | dialog_manager.dialog_data['service_new_title'] = markupsafe.escape(message.text) 59 | 60 | await dialog_manager.switch_to(state=EditMenu.CHECK_TITLE_CHANGE) 61 | 62 | 63 | async def edit_months_handler( 64 | message: Message, protocol: DialogProtocol, dialog_manager: DialogManager 65 | ) -> Message: 66 | l10n = dialog_manager.middleware_data['l10n'] 67 | session = dialog_manager.middleware_data['session'] 68 | 69 | try: 70 | value = int(message.text) 71 | except ValueError: 72 | return await message.answer( 73 | l10n.format_value('error-unsupported-char') 74 | ) 75 | 76 | if value not in range(1, 12 + 1): 77 | return await message.answer(l10n.format_value('error-range-reached')) 78 | 79 | service_id = dialog_manager.dialog_data['service_id'] 80 | service = await session.get_service(service_id=service_id) 81 | 82 | dialog_manager.dialog_data['service_old_months'] = service.months 83 | dialog_manager.dialog_data['service_new_months'] = int(message.text) 84 | 85 | await dialog_manager.switch_to(state=EditMenu.CHECK_MONTHS_CHANGE) 86 | 87 | 88 | async def edit_reminder_handler( 89 | callback: CallbackQuery, 90 | button: Button, 91 | dialog_manager: DialogManager, 92 | selected_date: date, 93 | ) -> None: 94 | session = dialog_manager.middleware_data['session'] 95 | 96 | service_id = dialog_manager.dialog_data['service_id'] 97 | service = await session.get_service(service_id=service_id) 98 | 99 | dialog_manager.dialog_data['service_old_reminder'] = service.reminder.date().isoformat() 100 | dialog_manager.dialog_data['service_new_reminder'] = selected_date.isoformat() 101 | 102 | await dialog_manager.switch_to(state=EditMenu.CHECK_REMINDER_CHANGE) 103 | 104 | 105 | async def reject_edit_menu( 106 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 107 | ) -> None: 108 | l10n = dialog_manager.middleware_data['l10n'] 109 | 110 | await callback.message.edit_text(l10n.format_value('reject-sub-edit')) 111 | await dialog_manager.done() 112 | await dialog_manager.start(state=EditMenu.EDIT, mode=StartMode.RESET_STACK) 113 | 114 | 115 | async def approve_edit_menu( 116 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 117 | ) -> None: 118 | l10n = dialog_manager.middleware_data['l10n'] 119 | session = dialog_manager.middleware_data['session'] 120 | 121 | if dialog_manager.dialog_data.get('service_new_title'): 122 | await session.edit_sub_title( 123 | service_id=dialog_manager.dialog_data['service_id'], 124 | title=dialog_manager.dialog_data['service_new_title'], 125 | ) 126 | if dialog_manager.dialog_data.get('service_new_months'): 127 | await session.edit_sub_months( 128 | service_id=dialog_manager.dialog_data['service_id'], 129 | months=dialog_manager.dialog_data['service_new_months'], 130 | ) 131 | if dialog_manager.dialog_data.get('service_new_reminder'): 132 | await session.edit_sub_date( 133 | service_id=dialog_manager.dialog_data['service_id'], 134 | reminder=datetime.fromisoformat( 135 | dialog_manager.dialog_data['service_new_reminder'] 136 | ), 137 | ) 138 | await session.commit() 139 | 140 | await callback.message.edit_text(l10n.format_value('approve-sub-edit')) 141 | await dialog_manager.done() 142 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK) 143 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/extras/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/extras/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/extras/calendar.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Dict 3 | 4 | from aiogram_dialog import DialogManager 5 | from aiogram_dialog.widgets.kbd import Calendar, CalendarScope 6 | from aiogram_dialog.widgets.kbd.calendar_kbd import ( 7 | CalendarDaysView, 8 | CalendarMonthView, 9 | CalendarScopeView, 10 | CalendarYearsView, 11 | ) 12 | from aiogram_dialog.widgets.text import Format, Text 13 | from babel.dates import get_day_names, get_month_names 14 | 15 | 16 | class WeekDay(Text): 17 | async def _render_text(self, data, dialog_manager: DialogManager) -> str: 18 | selected_date: date = data['date'] 19 | session = dialog_manager.middleware_data['session'] 20 | language = await session.get_language(user_id=dialog_manager.event.from_user.id) 21 | 22 | return get_day_names( 23 | width='short', 24 | context='stand-alone', 25 | locale=language, 26 | )[selected_date.weekday()].title() 27 | 28 | 29 | class Month(Text): 30 | async def _render_text(self, data, dialog_manager: DialogManager) -> str: 31 | selected_date: date = data['date'] 32 | session = dialog_manager.middleware_data['session'] 33 | language = await session.get_language(user_id=dialog_manager.event.from_user.id) 34 | 35 | return get_month_names( 36 | 'wide', 37 | context='stand-alone', 38 | locale=language, 39 | )[selected_date.month].title() 40 | 41 | 42 | class CustomCalendar(Calendar): 43 | def _init_views(self) -> Dict[CalendarScope, CalendarScopeView]: 44 | return { 45 | CalendarScope.DAYS: CalendarDaysView( 46 | self._item_callback_data, 47 | self.config, 48 | header_text=Month(), 49 | weekday_text=WeekDay(), 50 | next_month_text=Month() + ' →', 51 | prev_month_text='← ' + Month(), 52 | ), 53 | CalendarScope.MONTHS: CalendarMonthView( 54 | self._item_callback_data, 55 | self.config, 56 | month_text=Month(), 57 | header_text=Format('{date:%Y}'), 58 | this_month_text='[ ' + Month() + ' ]', 59 | ), 60 | CalendarScope.YEARS: CalendarYearsView( 61 | self._item_callback_data, 62 | self.config, 63 | ), 64 | } 65 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/extras/i18n_format.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Protocol 2 | 3 | from aiogram_dialog.api.protocols import DialogManager 4 | from aiogram_dialog.widgets.common import WhenCondition 5 | from aiogram_dialog.widgets.text import Text 6 | 7 | I18N_FORMAT_KEY = 'aiogd_i18n_format' 8 | 9 | 10 | class Values(Protocol): 11 | def __getitem__(self, item: Any) -> Any: 12 | raise NotImplementedError 13 | 14 | 15 | def default_format_text(text: str, data: Values) -> str: 16 | return text.format_map(data) 17 | 18 | 19 | class I18NFormat(Text): 20 | def __init__(self, text: str, when: WhenCondition = None): 21 | super().__init__(when) 22 | self.text = text 23 | 24 | async def _render_text(self, data: Dict, manager: DialogManager) -> str: 25 | format_text = manager.middleware_data.get( 26 | I18N_FORMAT_KEY, 27 | default_format_text, 28 | ) 29 | return format_text(self.text, data) 30 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/main_menu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/dialogs/main_menu/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/main_menu/dialog.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from aiogram_dialog import Dialog, Window 4 | from aiogram_dialog.widgets.kbd import ( 5 | Button, 6 | Group, 7 | Row, 8 | Select, 9 | Url, 10 | SwitchTo, 11 | ) 12 | from aiogram_dialog.widgets.text import Const, Format 13 | 14 | from src.presentation.tgbot.states.user import MainMenu 15 | from ..create_menu.getters import get_subs_for_output 16 | from ..delete_menu.handlers import on_click_get_delete_menu 17 | from ..edit_menu.handlers import on_click_get_edit_menu 18 | from ..extras.i18n_format import I18NFormat 19 | from ..main_menu.getters import get_langs_for_output 20 | from ..main_menu.handler import ( 21 | on_click_back_to_main_menu, 22 | on_click_change_lang, 23 | on_click_get_subs_menu, 24 | on_click_sub_create, 25 | ) 26 | 27 | main_menu = Dialog( 28 | Window( 29 | I18NFormat('start-menu'), 30 | Group( 31 | Button( 32 | I18NFormat('my-subscriptions'), id='subs_id', on_click=on_click_get_subs_menu, 33 | ), 34 | Row( 35 | SwitchTo( 36 | I18NFormat('settings'), id='settings_id', state=MainMenu.SETTINGS, 37 | ), 38 | SwitchTo( 39 | I18NFormat('support'), id='support_id', state=MainMenu.SUPPORT, 40 | ), 41 | ), 42 | ), 43 | state=MainMenu.MAIN, 44 | ), 45 | Window( 46 | I18NFormat('faq'), 47 | Group( 48 | Row( 49 | Url( 50 | I18NFormat('administrator'), 51 | Const('tg://user?id=878406427'), 52 | ), 53 | Url( 54 | Const('🐈 GitHub'), 55 | Const('https://github.com/Markushik/controller-new/'), 56 | ), 57 | ), 58 | Button( 59 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu, 60 | ), 61 | ), 62 | state=MainMenu.SUPPORT, 63 | ), 64 | Window( 65 | I18NFormat('catalog-add'), 66 | Group( 67 | Row( 68 | Button( 69 | I18NFormat('add'), id='add_id', on_click=on_click_sub_create, 70 | ), 71 | Button( 72 | I18NFormat('change'), id='change_id', on_click=on_click_get_edit_menu, 73 | ), 74 | Button( 75 | I18NFormat('delete'), id='remove_id', on_click=on_click_get_delete_menu, 76 | ), 77 | ), 78 | Button( 79 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu, 80 | ), 81 | ), 82 | state=MainMenu.CONTROL, 83 | getter=get_subs_for_output, 84 | ), 85 | Window( 86 | I18NFormat('select-lang'), 87 | Group( 88 | Row( 89 | Select( 90 | Format('{item[1]}'), 91 | id='lang_id', 92 | item_id_getter=operator.itemgetter(0), 93 | items='langs', 94 | on_click=on_click_change_lang, 95 | ), 96 | ), 97 | Button( 98 | I18NFormat('back'), id='back_id', on_click=on_click_back_to_main_menu, 99 | ), 100 | ), 101 | state=MainMenu.SETTINGS, 102 | getter=get_langs_for_output, 103 | ), 104 | ) 105 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/main_menu/getters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiogram_dialog import DialogManager 4 | 5 | from src.presentation.tgbot.constants import LANGUAGES 6 | 7 | 8 | async def get_langs_for_output(**kwargs) -> dict[str, list[Any]]: 9 | return {'langs': [item for item in enumerate(LANGUAGES)]} 10 | 11 | 12 | async def get_input_service_data( 13 | dialog_manager: DialogManager, **kwargs 14 | ) -> dict[str, Any | None]: 15 | return { 16 | 'service': dialog_manager.dialog_data.get('service'), 17 | 'months': dialog_manager.dialog_data.get('months'), 18 | 'reminder': dialog_manager.dialog_data.get('reminder'), 19 | } 20 | -------------------------------------------------------------------------------- /src/presentation/tgbot/dialogs/main_menu/handler.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CallbackQuery 2 | from aiogram_dialog import DialogManager, DialogProtocol, StartMode 3 | from aiogram_dialog.widgets.kbd import Button 4 | 5 | from src.presentation.tgbot.dialogs.extras.i18n_format import I18N_FORMAT_KEY 6 | from src.presentation.tgbot.states.user import MainMenu, CreateMenu, DeleteMenu 7 | 8 | 9 | async def on_click_get_subs_menu( 10 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 11 | ) -> None: 12 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK) 13 | 14 | 15 | async def on_click_back_to_main_menu( 16 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 17 | ) -> None: 18 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK) 19 | 20 | 21 | async def on_click_sub_create( 22 | callback: CallbackQuery, protocol: DialogProtocol, dialog_manager: DialogManager 23 | ) -> None: 24 | l10n = dialog_manager.middleware_data['l10n'] 25 | session = dialog_manager.middleware_data['session'] 26 | 27 | count_subs = await session.get_quantity_subs(user_id=dialog_manager.event.from_user.id) 28 | 29 | if count_subs < 7: 30 | return await dialog_manager.start(state=CreateMenu.TITLE, mode=StartMode.RESET_STACK) 31 | 32 | await callback.message.edit_text(l10n.format_value('error-subs-limit')) 33 | await dialog_manager.done() 34 | await dialog_manager.start(state=MainMenu.CONTROL, mode=StartMode.RESET_STACK) 35 | 36 | 37 | async def on_click_sub_delete( 38 | callback: CallbackQuery, button: Button, dialog_manager: DialogManager 39 | ) -> None: 40 | l10n = dialog_manager.middleware_data['l10n'] 41 | session = dialog_manager.middleware_data['session'] 42 | 43 | await session.delete_subscription(service_id=dialog_manager.dialog_data['service_id']) 44 | await session.decrement_quantity(user_id=dialog_manager.event.from_user.id) 45 | await session.commit() 46 | 47 | await callback.message.edit_text(l10n.format_value('approve-sub-delete')) 48 | await dialog_manager.done() 49 | await dialog_manager.start(state=DeleteMenu.DELETE, mode=StartMode.RESET_STACK) 50 | 51 | 52 | async def update_format_key( 53 | dialog_manager: DialogManager, language: str 54 | ) -> None: 55 | l10n = dialog_manager.middleware_data['l10ns'][language] 56 | dialog_manager.middleware_data[I18N_FORMAT_KEY] = l10n.format_value 57 | return 58 | 59 | 60 | async def on_click_change_lang( 61 | callback: CallbackQuery, 62 | button: Button, 63 | dialog_manager: DialogManager, 64 | item_id: str, 65 | ) -> None: 66 | session = dialog_manager.middleware_data['session'] 67 | 68 | language = None 69 | 70 | if item_id == '0': 71 | language = 'ru_RU' 72 | await callback.answer('Вы сменили язык на 🇷🇺 Русский') 73 | if item_id == '1': 74 | language = 'en_GB' 75 | await callback.answer('You switched language to 🇬🇧 English') 76 | 77 | await update_format_key(dialog_manager=dialog_manager, language=language) 78 | await session.update_language(user_id=dialog_manager.event.from_user.id, language=language) 79 | await session.commit() 80 | -------------------------------------------------------------------------------- /src/presentation/tgbot/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/handlers/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/handlers/client.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router 2 | from aiogram.filters import CommandStart, StateFilter 3 | from aiogram.types import Message, CallbackQuery 4 | from aiogram_dialog import DialogManager, StartMode 5 | 6 | from src.presentation.tgbot.keyboards.inline import CallbackExtensionBody 7 | from src.presentation.tgbot.states.user import CreateMenu, MainMenu 8 | 9 | router = Router() 10 | 11 | 12 | @router.callback_query(CallbackExtensionBody.filter(F.extension == 'extension')) 13 | async def command_extension( 14 | callback: CallbackQuery, 15 | dialog_manager: DialogManager, 16 | callback_data: CallbackExtensionBody, 17 | ) -> None: 18 | await dialog_manager.done() 19 | await dialog_manager.start(state=CreateMenu.REMINDER, mode=StartMode.NORMAL) 20 | 21 | dialog_manager.dialog_data['service'] = callback_data.service 22 | dialog_manager.dialog_data['months'] = callback_data.months 23 | 24 | await callback.answer() 25 | 26 | 27 | @router.message(CommandStart(), StateFilter('*')) 28 | async def command_start( 29 | message: Message, dialog_manager: DialogManager 30 | ) -> None: 31 | session = dialog_manager.middleware_data['session'] 32 | user = await session.get_user(user_id=message.from_user.id) 33 | 34 | if user is None: 35 | await session.add_user( 36 | user_id=message.from_user.id, 37 | user_name=message.from_user.first_name, 38 | chat_id=message.chat.id, 39 | ) 40 | await session.commit() 41 | 42 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK) 43 | -------------------------------------------------------------------------------- /src/presentation/tgbot/handlers/errors.py: -------------------------------------------------------------------------------- 1 | from aiogram_dialog import DialogManager, StartMode 2 | from loguru import logger 3 | 4 | from src.presentation.tgbot.states.user import MainMenu 5 | 6 | 7 | async def on_unknown_intent(event, dialog_manager: DialogManager): 8 | logger.error('Restarting dialog: %s', event.exception) 9 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK) 10 | 11 | 12 | async def on_unknown_state(event, dialog_manager: DialogManager): 13 | logger.error('Restarting dialog: %s', event.exception) 14 | await dialog_manager.start(state=MainMenu.MAIN, mode=StartMode.RESET_STACK) 15 | -------------------------------------------------------------------------------- /src/presentation/tgbot/keyboards/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/keyboards/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/keyboards/inline.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | 6 | class CallbackExtensionBody(CallbackData, prefix='extension'): 7 | extension: str 8 | service: str 9 | months: int 10 | 11 | 12 | def get_extension_menu( 13 | text: str, service: str, months: int, 14 | ) -> InlineKeyboardMarkup: 15 | menu_builder = InlineKeyboardBuilder() 16 | 17 | menu_builder.row( 18 | InlineKeyboardButton( 19 | text=text, 20 | callback_data=CallbackExtensionBody( 21 | extension='extension', service=service, months=months, 22 | ).pack() 23 | ) 24 | ) 25 | 26 | return menu_builder.as_markup() 27 | -------------------------------------------------------------------------------- /src/presentation/tgbot/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/middlewares/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/middlewares/database.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Dict 2 | 3 | from aiogram import BaseMiddleware 4 | from aiogram.types import TelegramObject 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | from src.infrastructure.database.adapter.adapter import DbAdapter 8 | 9 | 10 | class DbSessionMiddleware(BaseMiddleware): 11 | def __init__(self, session_maker: async_sessionmaker): 12 | super().__init__() 13 | self.session_maker = session_maker 14 | 15 | async def __call__( 16 | self, 17 | handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], 18 | event: TelegramObject, 19 | data: Dict[str, Any], 20 | ) -> Any: 21 | async with self.session_maker() as session: 22 | data['session'] = DbAdapter(session=session) 23 | return await handler(event, data) 24 | -------------------------------------------------------------------------------- /src/presentation/tgbot/middlewares/i18n.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Awaitable, Callable, Dict, Union 3 | 4 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 5 | from aiogram.types import CallbackQuery, Message 6 | from fluent.runtime import FluentLocalization, FluentResourceLoader 7 | 8 | from src.infrastructure.database.adapter.adapter import DbAdapter 9 | from src.presentation.tgbot.constants import DEFAULT_LOCALE, LOCALES 10 | from src.presentation.tgbot.dialogs.extras.i18n_format import I18N_FORMAT_KEY 11 | 12 | 13 | def make_i18n_middleware(): 14 | loader = FluentResourceLoader( 15 | os.path.join( 16 | os.path.dirname(__file__), '../../', 'locales', '{locale}' 17 | ) 18 | ) 19 | l10ns = { 20 | locale: FluentLocalization( 21 | [locale, DEFAULT_LOCALE], 22 | ['main.ftl'], 23 | loader, 24 | ) 25 | for locale in LOCALES 26 | } 27 | return I18nMiddleware(l10ns, DEFAULT_LOCALE) 28 | 29 | 30 | class I18nMiddleware(BaseMiddleware): 31 | def __init__( 32 | self, 33 | l10ns: Dict[str, FluentLocalization], 34 | default_lang: str, 35 | ): 36 | super().__init__() 37 | self.l10ns = l10ns 38 | self.default_lang = default_lang 39 | 40 | async def __call__( 41 | self, 42 | handler: Callable[ 43 | [Union[Message, CallbackQuery], Dict[str, Any]], 44 | Awaitable[Any], 45 | ], 46 | event: Union[Message, CallbackQuery], 47 | data: Dict[str, Any], 48 | ) -> Any: 49 | session: DbAdapter = data['session'] 50 | language = await session.get_language(user_id=event.from_user.id) 51 | 52 | language = language or 'ru_RU' 53 | l10n = self.l10ns[language] 54 | 55 | data['l10n'] = l10n 56 | data['l10ns'] = self.l10ns 57 | data[I18N_FORMAT_KEY] = l10n.format_value 58 | 59 | return await handler(event, data) 60 | -------------------------------------------------------------------------------- /src/presentation/tgbot/states/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Markushik/controller-new/a459ad6cce15b9544e855aba61db74f66f45a5c3/src/presentation/tgbot/states/__init__.py -------------------------------------------------------------------------------- /src/presentation/tgbot/states/user.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.state import State, StatesGroup 2 | 3 | 4 | class MainMenu(StatesGroup): 5 | MAIN = State() 6 | CONTROL = State() 7 | SETTINGS = State() 8 | SUPPORT = State() 9 | 10 | 11 | class CreateMenu(StatesGroup): 12 | TITLE = State() 13 | MONTHS = State() 14 | REMINDER = State() 15 | CHECK_ADD = State() 16 | 17 | 18 | class EditMenu(StatesGroup): 19 | EDIT = State() 20 | PARAMETERS = State() 21 | TITLE = State() 22 | MONTHS = State() 23 | REMINDER = State() 24 | 25 | CHECK_TITLE_CHANGE = State() 26 | CHECK_MONTHS_CHANGE = State() 27 | CHECK_REMINDER_CHANGE = State() 28 | 29 | 30 | class DeleteMenu(StatesGroup): 31 | DELETE = State() 32 | CHECK_DELETE = State() 33 | --------------------------------------------------------------------------------