├── .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 | 
4 | 
5 | [](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 | 
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 |
--------------------------------------------------------------------------------