├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.MD ├── config.py ├── docker-compose.yml ├── main.py ├── requirements.txt ├── setup.cfg ├── start.sh ├── telegram ├── __init__.py ├── filters │ ├── __init__.py │ └── custom_filter.py ├── handlers │ ├── __init__.py │ └── main_handler.py ├── keyboards │ ├── __init__.py │ └── main_menu.py ├── lexicon │ ├── __init__.py │ └── lexicon.py └── utils.py └── vpnworks └── api.py /.dockerignore: -------------------------------------------------------------------------------- 1 | /output 2 | /venv 3 | .gitignore 4 | __pycache__ 5 | .idea 6 | .github 7 | README.MD -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test. Build. Deploy. 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | flake8: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.9 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install flake8==6.0.0 flake8-isort==6.0.0 22 | pip install -r ./requirements.txt 23 | - name: Test with flake8 24 | run: | 25 | python -m flake8 . 26 | 27 | build-test: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v3 32 | 33 | - name: Set up Docker 34 | uses: docker/setup-buildx-action@v2 35 | 36 | - name: Build and run Docker container 37 | env: 38 | BOT_API: ${{ secrets.BOT_API_TEST }} 39 | CHAT_ID: 200100001 40 | run: | 41 | echo "BOT_API=${BOT_API}" >> .env 42 | echo "CHAT_ID=${CHAT_ID}" >> .env 43 | mkdir wireguard 44 | cd wireguard 45 | echo "${{ secrets.WG0_CONF }}" > wg0.conf 46 | cd .. 47 | docker compose up -d 48 | sleep 20 49 | - name: Verify Docker Images 50 | run: docker images 51 | - name: Check Docker logs 52 | run: | 53 | docker compose ls 54 | docker compose logs 55 | 56 | - name: Stop Docker container 57 | run: | 58 | set -e 59 | docker compose down 60 | 61 | deploy: 62 | needs: build-test 63 | runs-on: ubuntu-latest 64 | 65 | steps: 66 | - name: Stop containers and clean directory 67 | uses: appleboy/ssh-action@master 68 | with: 69 | host: ${{ secrets.HOST }} 70 | username: ${{ secrets.USER }} 71 | key: ${{ secrets.SSH_KEY }} 72 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 73 | script: | 74 | set -e 75 | cd ${{ secrets.PROJECT_PATH }} 76 | if [ -f docker-compose.yml ]; then 77 | sudo docker compose down 78 | sudo docker image prune -a -f 79 | fi 80 | rm -rf * 81 | - name: Checkout repository 82 | uses: actions/checkout@v3 83 | 84 | - name: Copy code via ssh 85 | uses: appleboy/scp-action@master 86 | with: 87 | host: ${{ secrets.HOST }} 88 | username: ${{ secrets.USER }} 89 | key: ${{ secrets.SSH_KEY }} 90 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 91 | source: "." 92 | target: "${{ secrets.PROJECT_PATH }}" 93 | - name: Create .env file and fill it with Github secrets 94 | uses: appleboy/ssh-action@master 95 | with: 96 | host: ${{ secrets.HOST }} 97 | username: ${{ secrets.USER }} 98 | key: ${{ secrets.SSH_KEY }} 99 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 100 | script: | 101 | set -e 102 | cd ${{ secrets.PROJECT_PATH }} 103 | rm -rf .env 104 | echo BOT_API=${{ secrets.BOT_API }} >> .env 105 | echo CHAT_ID=${{ secrets.CHAT_ID }} >> .env 106 | echo START_MSG=${{ secrets.START_MSG }} >> .env 107 | - name: Create wg0.conf file 108 | uses: appleboy/ssh-action@master 109 | with: 110 | host: ${{ secrets.HOST }} 111 | username: ${{ secrets.USER }} 112 | key: ${{ secrets.SSH_KEY }} 113 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 114 | script: | 115 | set -e 116 | cd ${{ secrets.PROJECT_PATH }} 117 | mkdir wireguard 118 | cd wireguard 119 | echo "${{ secrets.WG0_CONF }}" > wg0.conf 120 | - name: Executing remote ssh commands to deploy 121 | uses: appleboy/ssh-action@master 122 | with: 123 | host: ${{ secrets.HOST }} 124 | username: ${{ secrets.USER }} 125 | key: ${{ secrets.SSH_KEY }} 126 | passphrase: ${{ secrets.SSH_PASSPHRASE }} 127 | script: | 128 | set -e 129 | cd ${{ secrets.PROJECT_PATH }} 130 | sudo docker compose up -d --build 131 | sudo docker image prune -a -f 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### PyCharm template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # AWS User-specific 13 | .idea/**/aws.xml 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/artifacts 36 | # .idea/compiler.xml 37 | # .idea/jarRepositories.xml 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # SonarLint plugin 66 | .idea/sonarlint/ 67 | 68 | # Crashlytics plugin (for Android Studio and IntelliJ) 69 | com_crashlytics_export_strings.xml 70 | crashlytics.properties 71 | crashlytics-build.properties 72 | fabric.properties 73 | 74 | # Editor-based Rest Client 75 | .idea/httpRequests 76 | 77 | # Android studio 3.1+ serialized cache file 78 | .idea/caches/build_file_checksums.ser 79 | 80 | ### Python template 81 | # Byte-compiled / optimized / DLL files 82 | __pycache__/ 83 | *.py[cod] 84 | *$py.class 85 | 86 | # C extensions 87 | *.so 88 | 89 | # Distribution / packaging 90 | .Python 91 | build/ 92 | develop-eggs/ 93 | dist/ 94 | downloads/ 95 | eggs/ 96 | .eggs/ 97 | lib/ 98 | lib64/ 99 | parts/ 100 | sdist/ 101 | var/ 102 | wheels/ 103 | share/python-wheels/ 104 | *.egg-info/ 105 | .installed.cfg 106 | *.egg 107 | MANIFEST 108 | 109 | # PyInstaller 110 | # Usually these files are written by a python script from a template 111 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 112 | *.manifest 113 | *.spec 114 | 115 | # Installer logs 116 | pip-log.txt 117 | pip-delete-this-directory.txt 118 | 119 | # Unit test / coverage reports 120 | htmlcov/ 121 | .tox/ 122 | .nox/ 123 | .coverage 124 | .coverage.* 125 | .cache 126 | nosetests.xml 127 | coverage.xml 128 | *.cover 129 | *.py,cover 130 | .hypothesis/ 131 | .pytest_cache/ 132 | cover/ 133 | 134 | # Translations 135 | *.mo 136 | *.pot 137 | 138 | # Django stuff: 139 | *.log 140 | local_settings.py 141 | db.sqlite3 142 | db.sqlite3-journal 143 | 144 | # Flask stuff: 145 | instance/ 146 | .webassets-cache 147 | 148 | # Scrapy stuff: 149 | .scrapy 150 | 151 | # Sphinx documentation 152 | docs/_build/ 153 | 154 | # PyBuilder 155 | .pybuilder/ 156 | target/ 157 | 158 | # Jupyter Notebook 159 | .ipynb_checkpoints 160 | 161 | # IPython 162 | profile_default/ 163 | ipython_config.py 164 | 165 | # pyenv 166 | # For a library or package, you might want to ignore these files since the code is 167 | # intended to run in multiple environments; otherwise, check them in: 168 | # .python-version 169 | 170 | # pipenv 171 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 172 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 173 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 174 | # install all needed dependencies. 175 | #Pipfile.lock 176 | 177 | # poetry 178 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 179 | # This is especially recommended for binary packages to ensure reproducibility, and is more 180 | # commonly ignored for libraries. 181 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 182 | #poetry.lock 183 | 184 | # pdm 185 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 186 | #pdm.lock 187 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 188 | # in version control. 189 | # https://pdm.fming.dev/#use-with-ide 190 | .pdm.toml 191 | 192 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 193 | __pypackages__/ 194 | 195 | # Celery stuff 196 | celerybeat-schedule 197 | celerybeat.pid 198 | 199 | # SageMath parsed files 200 | *.sage.py 201 | 202 | # Environments 203 | .env 204 | .venv 205 | env/ 206 | venv/ 207 | ENV/ 208 | env.bak/ 209 | venv.bak/ 210 | 211 | # Spyder project settings 212 | .spyderproject 213 | .spyproject 214 | 215 | # Rope project settings 216 | .ropeproject 217 | 218 | # mkdocs documentation 219 | /site 220 | 221 | # mypy 222 | .mypy_cache/ 223 | .dmypy.json 224 | dmypy.json 225 | 226 | # Pyre type checker 227 | .pyre/ 228 | 229 | # pytype static type analyzer 230 | .pytype/ 231 | 232 | # Cython debug symbols 233 | cython_debug/ 234 | 235 | # PyCharm 236 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 237 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 238 | # and can be added to the global gitignore or merged into this file. For a more nuclear 239 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 240 | .idea/ 241 | output 242 | coredns 243 | templates 244 | *.conf 245 | wireguard/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | # Set work directory 3 | WORKDIR ./ 4 | # Set environment variables 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | COPY ./requirements.txt . 9 | # Install dependencies 10 | RUN pip install --no-cache-dir -r ./requirements.txt 11 | # Copy project to workdir 12 | COPY . . 13 | 14 | # Make start.sh executable 15 | RUN chmod +x /start.sh 16 | 17 | # Run start.sh when the container launches 18 | CMD ["/start.sh"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Valentin 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 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # VPN Bot Manager 2 | 3 | ![Python](https://img.shields.io/badge/python-v3.9-blue) 4 | ![Docker](https://img.shields.io/badge/docker-latest-blue) 5 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2F4erdenko%2FVPN-Generator-Manager%2Fbadge&style=flat)](https://actions-badge.atrox.dev/4erdenko/VPN-Generator-Manager/goto) 6 | This is a Telegram bot for managing VPN servers that you've received from the free VPN distribution service in Russia [VPNgen](https://vpngen.org/ru/#generator). If you're a VPN server manager (brigadier) from VPNgen, you can use this bot to manage your server users directly from Telegram. 7 | 8 | The bot runs inside a Docker container, and it can be automatically deployed using a GitHub Actions CI/CD pipeline. 9 | 10 | ## Features 11 | 12 | - Manage users of your VPN server 13 | - Get a list of users 14 | - Perform actions on user accounts 15 | - Check user activity 16 | - Receive notifications about server status 17 | - Use either as a standalone application or inside a Docker container 18 | 19 | ![Bot Demo](https://i.imgur.com/G0p6rmI.png) 20 | 21 | ## Requirements 22 | 23 | - Python 3.9+ 24 | - Docker (for container deployment) 25 | - A VPN server from VPNgen 26 | - A Wireguard `wg0.conf` file from the VPN server 27 | - A Telegram bot API token 28 | 29 | ## Quickstart 30 | 31 | Clone this repository to your machine: 32 | 33 | ```bash 34 | git clone https://github.com/username/repo.git 35 | cd repo 36 | ``` 37 | 38 | ### Running Locally 39 | 40 | If you want to run the bot on your local machine, follow these steps: 41 | 42 | 1. Install the necessary packages: 43 | 44 | ```bash 45 | pip install -r requirements.txt 46 | ``` 47 | 48 | 2. Create a `.env` file in the project's root directory with the following content: 49 | 50 | ```text 51 | BOT_API=your_bot_token 52 | CHAT_ID=your_chat_id 53 | START_MSG=your_welcome_message 54 | ``` 55 | 56 | 3. Run the bot: 57 | 58 | ```bash 59 | python main.py 60 | ``` 61 | 62 | ### Running in Docker 63 | 64 | To run the bot inside a Docker container, make sure Docker is installed and running on your machine: 65 | 66 | 1. Copy project folder and create .env file inside. 67 | 68 | 3. Run the Docker containers: 69 | (inside project folder) 70 | ```bash 71 | docker compuse up -d 72 | ``` 73 | 74 | Here, we are mounting the `wireguard` directory from your local machine into the Docker container, so that the bot can access the `wg0.conf` file. 75 | 76 | ## Automatic Deployment with GitHub Actions 77 | 78 | You can also set up automatic deployment of your bot using the provided GitHub Actions CI/CD pipeline. You will need to add the following secrets to your GitHub repository: 79 | 80 | - `HOST`: The IP address or hostname of your server. 81 | - `USER`: The username to use for SSH. 82 | - `SSH_KEY`: Your private SSH key for accessing the server. 83 | - `SSH_PASSPHRASE`: The passphrase for your SSH key, if any. 84 | - `PROJECT_PATH`: The path on your server where the bot will be deployed. 85 | - `BOT_API`: Your Telegram bot API token. 86 | - `BOT_API_TEST`: Bot API for tests in pipeline. 87 | - `CHAT_ID`: The chat ID where your bot will operate. 88 | - `START_MSG`: The start message for your bot. 89 | - `WG0_CONF`: The content of your `wg0.conf` file. 90 | 91 | Once these secrets are set up, the bot will automatically be deployed to your server whenever you push to the `master` branch. 92 | 93 | ## Contribution 94 | 95 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 96 | 97 | ## License 98 | 99 | [MIT](https://choosealicense.com/licenses/mit/) 100 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | # Specifies the path of the .env file which contains environment variables. 6 | dotenv_path = os.path.join(os.path.dirname(__file__), '.env') 7 | 8 | # Loads the .env file. 9 | load_dotenv(dotenv_path) 10 | 11 | # Gets the Bot API token from environment variable. 12 | BOT_API = os.getenv('BOT_API') 13 | 14 | # Gets the chat ID from environment variable and converts it to integer. 15 | CHAT_ID = int(os.getenv('CHAT_ID')) 16 | 17 | # Gets the start message from environment variable, 18 | # defaults to 'Hello World!' if not set. 19 | START_MSG = os.getenv('START_MSG', 'Hello World!') 20 | 21 | 22 | def check_credentials(): 23 | """ 24 | Checks if the Bot API token and chat ID are set in 25 | the environment variables. 26 | 27 | Returns: 28 | str: Error message if either the Bot API token or chat ID is not set. 29 | """ 30 | if not [BOT_API, CHAT_ID]: 31 | return 'You must create .env file with BOT_API and CHAT_ID' 32 | 33 | 34 | # Calls the check_credentials function to ensure the 35 | # Bot API token and chat ID are set. 36 | check_credentials() 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | wireguard: 5 | image: linuxserver/wireguard 6 | container_name: wireguard 7 | restart: unless-stopped 8 | volumes: 9 | - './wireguard:/config' 10 | - '/lib/modules:/lib/modules:ro' 11 | environment: 12 | - PUID=1000 13 | - PGID=1000 14 | cap_add: 15 | - NET_ADMIN 16 | - SYS_MODULE 17 | privileged: true 18 | sysctls: 19 | - net.ipv4.conf.all.src_valid_mark=1 20 | - net.ipv6.conf.all.disable_ipv6=0 21 | networks: 22 | - backbone 23 | 24 | telegram_bot: 25 | build: 26 | context: . 27 | container_name: TG_BOT 28 | restart: unless-stopped 29 | network_mode: service:wireguard 30 | depends_on: 31 | - wireguard 32 | 33 | networks: 34 | backbone: 35 | driver: bridge -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import sys 4 | 5 | import coloredlogs 6 | from aiogram import Bot, Dispatcher 7 | 8 | from config import BOT_API 9 | from telegram.handlers import main_handler 10 | from telegram.keyboards.main_menu import set_main_menu 11 | 12 | 13 | async def main(): 14 | logging.basicConfig( 15 | level=logging.INFO, 16 | format='%(asctime)s - %(levelname)s - %(name)s - %(message)s', 17 | stream=sys.stdout, 18 | ) 19 | coloredlogs.install( 20 | level='INFO', 21 | fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 22 | isatty=True, 23 | stream=sys.stdout, 24 | ) 25 | bot = Bot(token=BOT_API, parse_mode='HTML') 26 | dp = Dispatcher() 27 | await set_main_menu(bot) 28 | dp.include_router(main_handler.router) 29 | 30 | await bot.delete_webhook(drop_pending_updates=True) 31 | await dp.start_polling(bot) 32 | 33 | 34 | if __name__ == '__main__': 35 | asyncio.run(main()) 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==3.1.1 2 | pytz==2023.3.post1 3 | python-dotenv==1.0.0 4 | aiofiles~=23.1.0 5 | tenacity==8.2.3 6 | httpx==0.25.1 7 | coloredlogs~=15.0.1 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Не проверять код на соответствие стандартам W503 и F811 3 | ignore = 4 | W503, 5 | F811 6 | # Не проверять код в перечисленных директориях: 7 | exclude = 8 | venv/, 9 | */venv/, 10 | env/ 11 | */env/, 12 | *.github/, 13 | */.github/, 14 | coredns/, 15 | */coredns/, 16 | wireguard/, 17 | */wireguard/, 18 | templates/, 19 | */templates/, 20 | 21 | [isort] 22 | known_first_party = vpnworks, telegram, wireguard 23 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Wait for wireless network to come up 4 | sleep 15 5 | 6 | # Run the bot 7 | python3 main.py -------------------------------------------------------------------------------- /telegram/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4erdenko/VPN-Generator-Manager/b50a2e01045b9d07357934e6b5f9bebf846df2c9/telegram/__init__.py -------------------------------------------------------------------------------- /telegram/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4erdenko/VPN-Generator-Manager/b50a2e01045b9d07357934e6b5f9bebf846df2c9/telegram/filters/__init__.py -------------------------------------------------------------------------------- /telegram/filters/custom_filter.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters import BaseFilter 2 | from aiogram.types import Message 3 | 4 | from config import CHAT_ID 5 | 6 | 7 | class IsAdmin(BaseFilter): 8 | async def __call__(self, message: Message) -> bool: 9 | return message.from_user.id == CHAT_ID 10 | -------------------------------------------------------------------------------- /telegram/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4erdenko/VPN-Generator-Manager/b50a2e01045b9d07357934e6b5f9bebf846df2c9/telegram/handlers/__init__.py -------------------------------------------------------------------------------- /telegram/handlers/main_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiogram 4 | from aiogram import Bot, Router 5 | from aiogram.filters import Command, CommandStart 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.fsm.state import State, StatesGroup 8 | 9 | from config import START_MSG 10 | from telegram.filters.custom_filter import IsAdmin 11 | from telegram.utils import User, send_configs 12 | from vpnworks.api import VpnWorksApi 13 | 14 | logger = logging.getLogger(__name__) 15 | client = VpnWorksApi() 16 | router = Router() 17 | 18 | router.message.filter(IsAdmin()) 19 | 20 | 21 | class DeleteUserState(StatesGroup): 22 | """ 23 | A state group for tracking the process of a user deletion. 24 | """ 25 | 26 | waiting_for_user_id = State() 27 | 28 | 29 | @router.message(CommandStart()) 30 | async def start(message: aiogram.types.Message): 31 | """ 32 | The handler for the 'start' command. Sends a greeting message to the user. 33 | 34 | Args: 35 | message (aiogram.types.Message): The message from the user. 36 | """ 37 | await message.answer(START_MSG) 38 | logger.info(f'User {message.from_user.id} started bot') 39 | 40 | 41 | @router.message(Command(commands='make_config')) 42 | async def get_config(message: aiogram.types.Message, bot: Bot): 43 | """ 44 | The handler for the 'Make config' command. Sends a 45 | configuration file to the user. 46 | 47 | Args: 48 | message (aiogram.types.Message): The message from the user. 49 | :param message: 50 | :param bot: 51 | """ 52 | await send_configs(client, message, bot) 53 | 54 | 55 | @router.message(Command(commands='get_users')) 56 | async def get_users(message: aiogram.types.Message, bot: Bot): 57 | """ 58 | The handler for the 'Get users' command. Sends a list of users to the user. 59 | 60 | Args: 61 | message (aiogram.types.Message): The message from the user. 62 | :param message: 63 | :param bot: 64 | """ 65 | wait_message = await message.answer('Getting users...') 66 | user_data = await client.get_users() 67 | 68 | users = [User(data) for data in user_data] 69 | result = [user.format_message() for user in users] 70 | deactive_users = len( 71 | [user for user in users if not user.is_active and user.has_visited] 72 | ) 73 | not_entered_users = len( 74 | [user for user in users if not user.is_active and not user.has_visited] 75 | ) 76 | low_gb_quota_names = [user.name for user in users if user.has_low_quota] 77 | stats = await client.get_users_stats() 78 | active_users = stats.get('ActiveUsers').pop().get('Value') 79 | total_users = stats.get('TotalUsers').pop().get('Value') 80 | total_gb_quota = stats.get('TotalTrafficGB').pop().get('Value') 81 | 82 | low_quota_info = ( 83 | f'Low quota: {" | ".join(low_gb_quota_names)}' 84 | if low_gb_quota_names 85 | else '' 86 | ) 87 | summary_message = ( 88 | f'Total users: {total_users}\n' 89 | f'Active: {active_users} | Deactive: {deactive_users} | ' 90 | f'Not entered: {not_entered_users}\n' 91 | f'Total quota: {total_gb_quota}\n' 92 | f'{low_quota_info}' 93 | ) 94 | 95 | result.append(summary_message) 96 | 97 | await bot.edit_message_text( 98 | message_id=wait_message.message_id, 99 | chat_id=wait_message.chat.id, 100 | text=''.join(result), 101 | ) 102 | logger.info(f'User {message.from_user.id} get users') 103 | 104 | 105 | @router.message(Command(commands='delete_user')) 106 | async def start_delete_user(message: aiogram.types.Message, state: FSMContext): 107 | """ 108 | The handler for the 'Delete user' command. It asks the user to 109 | enter a user ID for deletion. 110 | 111 | Args: 112 | message (aiogram.types.Message): The message from the user. 113 | :param message: 114 | :param state: 115 | """ 116 | try: 117 | await message.answer('Enter user ID') 118 | await state.set_state(DeleteUserState.waiting_for_user_id) 119 | logger.info(f'User {message.from_user.id} start delete user') 120 | except Exception as e: 121 | await message.answer(f'Error: while start deleting user: {e}') 122 | logger.error(f'Error: while deleting user: {e}') 123 | 124 | 125 | @router.message(DeleteUserState.waiting_for_user_id) 126 | async def delete_user_id(message: aiogram.types.Message, state: FSMContext): 127 | """ 128 | The handler for the deletion of a user ID. It receives the ID from 129 | the user, attempts to delete the user 130 | with this ID and sends a result message to the user. 131 | 132 | Args: 133 | message (aiogram.types.Message): The message from the user. 134 | state (FSMContext): The finite state machine context to 135 | manage the states of the conversation. 136 | """ 137 | user_id = await client.get_user_id(message.text) 138 | if user_id and await client.delete_user(user_id) is not None: 139 | await message.answer('User deleted') 140 | logger.info(f'User {message.from_user.id} deleted user {user_id}') 141 | else: 142 | await message.answer('User not found') 143 | logger.info(f'User {message.from_user.id} not found user {user_id}') 144 | await state.clear() 145 | logger.info(f'User {message.from_user.id} finish delete user') 146 | -------------------------------------------------------------------------------- /telegram/keyboards/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4erdenko/VPN-Generator-Manager/b50a2e01045b9d07357934e6b5f9bebf846df2c9/telegram/keyboards/__init__.py -------------------------------------------------------------------------------- /telegram/keyboards/main_menu.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot 2 | from aiogram.types import BotCommand 3 | 4 | from telegram.lexicon.lexicon import LEXICON_COMMANDS 5 | 6 | 7 | async def set_main_menu(bot: Bot): 8 | main_menu_commands = [ 9 | BotCommand(command=command, description=description) 10 | for command, description in LEXICON_COMMANDS.items() 11 | ] 12 | await bot.set_my_commands(main_menu_commands) 13 | -------------------------------------------------------------------------------- /telegram/lexicon/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4erdenko/VPN-Generator-Manager/b50a2e01045b9d07357934e6b5f9bebf846df2c9/telegram/lexicon/__init__.py -------------------------------------------------------------------------------- /telegram/lexicon/lexicon.py: -------------------------------------------------------------------------------- 1 | LEXICON_COMMANDS: dict[str, str] = { 2 | '/make_config': '🔒 Create a new VPN config', 3 | '/get_users': '👤 Get info about the users', 4 | '/delete_user': '❌ Delete some user' 5 | } 6 | -------------------------------------------------------------------------------- /telegram/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from datetime import datetime 4 | 5 | from aiogram import Bot 6 | from aiogram.types import FSInputFile, InputMediaDocument, Message 7 | from pytz import timezone 8 | 9 | from vpnworks.api import VpnWorksApi 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def convert_date(date_str): 15 | """ 16 | Converts a UTC datetime string to a datetime string in the Moscow timezone. 17 | 18 | Args: 19 | date_str (str): UTC datetime string in the format 20 | '%Y-%m-%dT%H:%M:%S.%fZ'. 21 | 22 | Returns: 23 | str: Datetime string in the Moscow timezone in the format 24 | '%d-%m-%Y %H:%M'. 25 | """ 26 | dt = datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S.%fZ') 27 | dt = dt.replace(tzinfo=timezone('UTC')) 28 | dt = dt.astimezone(timezone('Europe/Moscow')) 29 | return dt.strftime('%d-%m-%Y %H:%M') 30 | 31 | 32 | class User: 33 | """ 34 | User class for handling and formatting user data. 35 | 36 | Attributes: 37 | data (dict): Dictionary of user data. 38 | name (str): User's name. 39 | status (str): User's status. 40 | last_visit (str): The last time the user visited. 41 | quota (int): Remaining monthly quota in GB. 42 | problems (str): Any problems associated with the user. 43 | """ 44 | 45 | def __init__(self, data): 46 | """ 47 | Initializes a User object with the provided data. 48 | 49 | Args: 50 | data (dict): Dictionary of user data. 51 | """ 52 | self.data = data 53 | self.name = self.data.get('UserName') 54 | self.status = self.data.get('Status') 55 | self.last_visit = ( 56 | convert_date(self.data.get('LastVisitHour')) 57 | if self.data.get('LastVisitHour') 58 | else None 59 | ) 60 | self.quota = self.data.get('MonthlyQuotaRemainingGB') 61 | self.problems = self.data.get('Problems') 62 | 63 | @property 64 | def is_active(self): 65 | """ 66 | Checks if the user is active. 67 | 68 | Returns: 69 | bool: True if the user's status is 'green' (active), 70 | False otherwise. 71 | """ 72 | return self.status == 'green' 73 | 74 | @property 75 | def has_visited(self): 76 | """ 77 | Checks if the user has visited. 78 | 79 | Returns: 80 | bool: True if the user's last visit is not None, False otherwise. 81 | """ 82 | return self.last_visit is not None 83 | 84 | @property 85 | def has_low_quota(self): 86 | """ 87 | Checks if the user has low quota remaining. 88 | 89 | Returns: 90 | bool: True if the user's quota is less than 10 GB, False otherwise. 91 | """ 92 | return self.quota < 10 93 | 94 | def format_message(self): 95 | """ 96 | Formats a message with the user's data. 97 | 98 | Returns: 99 | str: Formatted message with the user's data. 100 | """ 101 | status_icon = '🟩' if self.is_active else '🟥' 102 | problems_string = f'⛔: {self.problems} ' if self.problems else '' 103 | time_string = ( 104 | f'Last enter: {self.last_visit}' 105 | if self.last_visit 106 | else 'Last enter:' 107 | ) 108 | return ( 109 | f'{status_icon} :{self.name} ' 110 | f'{problems_string}' 111 | f'🔃: {self.quota} GB\n' 112 | f'{time_string}\n' 113 | f'--------------------------------------------------------\n' 114 | ) 115 | 116 | 117 | async def send_configs(client: VpnWorksApi, message: Message, bot: Bot): 118 | 119 | results = await client.create_conf_file() 120 | outline_key = results.get('outline') 121 | amnezia_filename = results.get('amnezia') 122 | wireguard_filename = results.get('wireguard') 123 | username = results.get('username') 124 | users_dict = await client.get_users_dict() 125 | person_name = users_dict.get(f'{username}').get('PersonName') 126 | person_desc = users_dict.get(f'{username}').get('PersonDesc') 127 | person_link = users_dict.get(f'{username}').get('PersonDescLink') 128 | 129 | caption_message = ( 130 | f'Outline key:\n' 131 | f'{outline_key}\n' 132 | f'\n\n{person_name}' 133 | f'\n{person_desc}\n\n' 134 | f'{username}' 135 | ) 136 | media_group = [ 137 | InputMediaDocument(media=FSInputFile(amnezia_filename)), 138 | InputMediaDocument( 139 | media=FSInputFile(wireguard_filename), caption=caption_message 140 | ), 141 | ] 142 | 143 | await bot.send_media_group(chat_id=message.chat.id, media=media_group) 144 | os.remove(wireguard_filename) 145 | os.remove(amnezia_filename) 146 | 147 | logger.info(f'User {message.from_user.id} get configs of {username}') 148 | -------------------------------------------------------------------------------- /vpnworks/api.py: -------------------------------------------------------------------------------- 1 | import aiofiles 2 | import httpx 3 | from tenacity import retry, stop_after_attempt 4 | 5 | 6 | class VpnWorksApi: 7 | base_url = 'https://vpn.works' 8 | 9 | def __init__(self): 10 | self._token = None 11 | self._base_headers = {'Accept': 'application/json, text/plain, */*'} 12 | self.token_headers = self._base_headers 13 | self.user_headers = self._base_headers 14 | self.config_headers = { 15 | **self._base_headers, 16 | 'Accept': 'application/json', 17 | } 18 | self.client = httpx.AsyncClient(verify=False) 19 | 20 | @property 21 | async def token(self): 22 | if self._token is None: 23 | await self._get_token() 24 | return self._token 25 | 26 | @token.setter 27 | def token(self, value): 28 | self._token = value 29 | 30 | async def _get_token(self): 31 | resp = await self.client.post(f'{self.base_url}/token') 32 | resp.raise_for_status() 33 | data = resp.json() 34 | self._token = data['Token'] 35 | self.user_headers['Authorization'] = f'Bearer {self._token}' 36 | self.config_headers['Authorization'] = f'Bearer {self._token}' 37 | 38 | @retry(stop=stop_after_attempt(3)) 39 | async def _make_request(self, endpoint, req_type='get', headers=None): 40 | headers = headers or self.user_headers 41 | method = getattr(self.client, req_type) 42 | resp = await method(f'{self.base_url}/{endpoint}', headers=headers) 43 | if resp.status_code == 401: 44 | await self._get_token() 45 | headers['Authorization'] = f'Bearer {self._token}' 46 | return await self._make_request( 47 | endpoint, 48 | req_type=req_type, 49 | headers=headers, 50 | ) 51 | resp.raise_for_status() 52 | return resp 53 | 54 | async def get_users(self): 55 | response = await self._make_request('user') 56 | return response.json() 57 | 58 | async def get_users_stats(self): 59 | response = await self._make_request('users/stats') 60 | return response.json() 61 | 62 | async def delete_user(self, UserID): 63 | return await self._make_request( 64 | f'user/{str(UserID)}', 65 | req_type='delete', 66 | ) 67 | 68 | async def _get_conf_file(self): 69 | response = await self._make_request( 70 | endpoint='user', headers=self.config_headers, req_type='post' 71 | ) 72 | return response.json() 73 | 74 | async def create_conf_file(self): 75 | data = await self._get_conf_file() 76 | username = data.get('UserName') 77 | results: dict[str, str] = { 78 | 'username': username, 79 | 'amnezia': '', 80 | 'wireguard': '', 81 | 'outline': '', 82 | } 83 | 84 | amnezia_config = data.get('AmnzOvcConfig') 85 | if amnezia_config: 86 | amnezia_filename = amnezia_config.get('FileName') 87 | amnezia_file_content = amnezia_config.get('FileContent') 88 | if amnezia_filename and amnezia_file_content: 89 | async with aiofiles.open(amnezia_filename, 'w') as file: 90 | await file.write(amnezia_file_content) 91 | results.update({'amnezia': amnezia_filename}) 92 | 93 | wireguard_config = data.get('WireguardConfig') 94 | if wireguard_config: 95 | wireguard_filename = wireguard_config.get('FileName') 96 | wireguard_file_content = wireguard_config.get('FileContent') 97 | if wireguard_filename and wireguard_file_content: 98 | async with aiofiles.open(wireguard_filename, 'w') as f: 99 | await f.write(wireguard_file_content) 100 | results.update({'wireguard': wireguard_filename}) 101 | 102 | outline_config = data.get('OutlineConfig') 103 | if outline_config: 104 | outline_key = outline_config.get('AccessKey') 105 | if outline_key: 106 | results.update({'outline': outline_key}) 107 | 108 | if not results: 109 | return 'No configurations provided' 110 | return results 111 | 112 | async def get_users_dict(self): 113 | users = await self.get_users() 114 | users_dict = {user['UserName']: user for user in users} 115 | return users_dict 116 | 117 | async def get_user_id(self, name): 118 | users_dict = await self.get_users_dict() 119 | return users_dict.get(str(name), {}).get('UserID') 120 | --------------------------------------------------------------------------------