Compare commits
375 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d30e932f94 | |||
| 87a3e124f4 | |||
| 579a8ca7e4 | |||
| b1276c42a4 | |||
| 0996f15c30 | |||
| b2fb41edc3 | |||
| 10ce155b8c | |||
| 0ca647fd64 | |||
| 33d4fd2e2e | |||
| 7a2f5cd899 | |||
| 6fbe1632bc | |||
| 36baacc0e1 | |||
| eb7effed4c | |||
| f27b62f8e7 | |||
| 4aa9be2408 | |||
| 4fca2cad95 | |||
| b41314af76 | |||
| ce3985ff96 | |||
| 9b9a72ef04 | |||
| ce78caf065 | |||
| c01b528055 | |||
| 725167f663 | |||
| 18dde7e882 | |||
| f5ec703994 | |||
| 5e07feb815 | |||
| c394fb3d2a | |||
| a7979af27a | |||
| d29d454727 | |||
| de6a79a634 | |||
| fd3d5d8de6 | |||
| f1358b36b7 | |||
| 831472ab51 | |||
| bbb554e4e9 | |||
| 7677ae9193 | |||
| ca87286898 | |||
| f821241edb | |||
| 9706730d37 | |||
| 8b11c65c8e | |||
| 2750517be4 | |||
| e5da607c9d | |||
| 1e912487af | |||
| 69f7d05527 | |||
| 2b572da089 | |||
| c75fd8516a | |||
| a81b09eb1e | |||
| 145c716529 | |||
| bc1ddc349a | |||
| 5f557fa2b2 | |||
| 940867baed | |||
| 4ea1f101ff | |||
| d0d36d9f1a | |||
| eee8d86426 | |||
| 94d335d950 | |||
| 9756d2e2b7 | |||
| 3e4d7972c7 | |||
| 0e9c128a15 | |||
| e850bd85a8 | |||
| b02c716992 | |||
| 822d845241 | |||
| 8dc478a606 | |||
| d664036f5e | |||
| fe655a7ee5 | |||
| 56ff84f7a5 | |||
| 69b1705fd5 | |||
| 956cdaf020 | |||
| 0b44237317 | |||
| 67880704a0 | |||
| 682fb0bdb0 | |||
| 71c7a10165 | |||
| 5577780565 | |||
| 5db2b7c861 | |||
| 370ceb8382 | |||
| 3226438e69 | |||
| c432edf8fc | |||
| 49aa863eb4 | |||
| c58039cb9e | |||
| ee242c98c7 | |||
| f3dfb03749 | |||
| 02ea333af1 | |||
| 142a5ffaab | |||
| db24a9abda | |||
| 3fcb6adaad | |||
| 5be9632c5e | |||
| 42faa00e32 | |||
| f6fbf31109 | |||
| 8751c9d4f2 | |||
| 04e1510001 | |||
| 2b1c62e869 | |||
| 2899de2632 | |||
| 9451deec4c | |||
| d8b0ff9961 | |||
| cb6bd1135b | |||
| 47210fc20a | |||
| 2815b8a340 | |||
| 1301b81e79 | |||
| 0353d9d628 | |||
| d0084f4062 | |||
| 4ce36e3f22 | |||
| 14cfe61e33 | |||
| 0e5961bdc5 | |||
| d9064ff016 | |||
| efc687d0fe | |||
| 1c3e42f94a | |||
| fa7347a85c | |||
| 7f1c783239 | |||
| 8c85bdd4d8 | |||
| 7991a3aa4d | |||
| 2c86417c9d | |||
| f18af7010f | |||
| bbcd72bab4 | |||
| 54d7299977 | |||
| ca8b032230 | |||
| be53b79221 | |||
| f6af8e3212 | |||
| a6e07c8080 | |||
| 7db545e249 | |||
| 7d751e0588 | |||
| 033b978a37 | |||
| d9676bd355 | |||
| 6a7890845d | |||
| 79ea8d431d | |||
| 27db7f6814 | |||
| 900ffd3e2e | |||
| 7eeacd94c1 | |||
| ac61783fef | |||
| 80838a2796 | |||
| b820a70f01 | |||
| e3584587b8 | |||
| 1370ff983c | |||
| 274bbc66be | |||
| 7a7f4ee3e2 | |||
| 1cbf0f643b | |||
| 0e5236184c | |||
| 55517c1398 | |||
| 12a2a12273 | |||
| 02120262cd | |||
| 89c5a4db2c | |||
| 2cfbfc5fd0 | |||
| 3f7d0a26fd | |||
| dccc6166e8 | |||
| a79c51dce4 | |||
| e99c41ae0c | |||
| 5ca72a4a65 | |||
| 97d4031f0c | |||
| 2b73c601d0 | |||
| c6d372c080 | |||
| 592afcf56e | |||
| fb64afe06e | |||
| 7d4a33e679 | |||
| 53dc006f2d | |||
| a672a0ddb0 | |||
| cfc3cedc2e | |||
| 7fe5c3d870 | |||
| 2e5bcbe407 | |||
| 0867ebdd61 | |||
| fe59470256 | |||
| 9cd7ea1451 | |||
| c55764d1a4 | |||
| 14364dbcfe | |||
| 4b05bd8e18 | |||
| 5363f15fe7 | |||
| 42ad24df1d | |||
| f7e03fb2b9 | |||
| 4e3fea2b11 | |||
| 32b9c0e779 | |||
| 50c7aa52c0 | |||
| f742d3bad5 | |||
| d100dc7b2d | |||
| 384c2c4c1c | |||
| 6f0cbf6c8c | |||
| ef850506d6 | |||
| 3a4ab1a956 | |||
| 187daba997 | |||
| 6d4c98b901 | |||
| 1119d607e8 | |||
| 6f2b9694f3 | |||
| 4b1549ca65 | |||
| acc7c701fe | |||
| 96a76f0425 | |||
| 2ab8d093dc | |||
| f0aa11f711 | |||
| 7a7f3cac95 | |||
| 9a44f4e37f | |||
| 1db43423fc | |||
| cf776c46c8 | |||
| 7628b8d45b | |||
| ba717bd5e5 | |||
| 3cea1f9653 | |||
| 713f0a0eac | |||
| 8d7c1cbcb8 | |||
| 97ca831e43 | |||
| 488f52248f | |||
| 956644fd57 | |||
| 942adc4490 | |||
| e04ccd893f | |||
| 12b9b9c77e | |||
| 663996493c | |||
| f4651b587e | |||
| a7c9556761 | |||
| 5f8280061a | |||
| 222803f147 | |||
| 9903298592 | |||
| e45b64e2ad | |||
| d33fe0040e | |||
| be0e9d46e9 | |||
| d5d61e4cc8 | |||
| 3573c8f7d0 | |||
| db206dc8be | |||
| 92f8a03f76 | |||
| ff9c01a941 | |||
| 20d424f2d4 | |||
| a91629bd7f | |||
| 7a02410f73 | |||
| 657035db5d | |||
| 1eab4cf29c | |||
| bea360b978 | |||
| 09251f1e9e | |||
| 755f871251 | |||
| c2cc341a34 | |||
| 35383724f1 | |||
| 5d4c55e5be | |||
| 722661888a | |||
| b080387f67 | |||
| 371cbcf10d | |||
| d3e88402cb | |||
| be3c2aa73a | |||
| d411ee5eff | |||
| f5b01c7b49 | |||
| 086f73cf6d | |||
| f996c16669 | |||
| e4e12c2dff | |||
| 8ba3195030 | |||
| e512ecae4f | |||
| 7eb89d7c23 | |||
| 8efafe206f | |||
| 0317b14d2e | |||
| 2a9c407439 | |||
| 2e6d3c8b12 | |||
| 74bb638806 | |||
| fe00b3534d | |||
| 16b438c93f | |||
| 304caf2242 | |||
| 37d3eeb277 | |||
| 3ee6449de0 | |||
| 116a65e48e | |||
| e61ab751bb | |||
| da99e358df | |||
| b9e9eeb214 | |||
| 124ec36661 | |||
| f29fd41a4c | |||
| 098b879c5c | |||
| 24abd4c087 | |||
| 85e5d96112 | |||
| 1a85ff4ae8 | |||
| e87df0c8eb | |||
| 213284f5f8 | |||
| 37110b230b | |||
| 8f12316584 | |||
| 85d6fbc3e3 | |||
| 317e591d1f | |||
| 0f0b2708e7 | |||
| fe6bbe7088 | |||
| bba036e7e9 | |||
| 2f10ae0b74 | |||
| deafbfb4ee | |||
| afe701ccaf | |||
| 9b8024b8c3 | |||
| 8de8236703 | |||
| 3fb4150a9e | |||
| 5257621f43 | |||
| 6ab6ac231a | |||
| be51a4cdbf | |||
| 2bbebe7d4f | |||
| 0a2b164c49 | |||
| 33c68527c2 | |||
| 3a46d12657 | |||
| 399537530a | |||
| f6aa28a0e7 | |||
| a7e5e74857 | |||
| 2f4caeeeb7 | |||
| 92af5e5d37 | |||
| 9b87d02b5d | |||
| 0f9d4e3935 | |||
| 23f668e251 | |||
| fa27648c7c | |||
| bc63a4b070 | |||
| 8c3e055acd | |||
| 1b7b7b1e18 | |||
| c63c8d08cf | |||
| 840ef3648b | |||
| 299a07e9a3 | |||
| abf5d686cb | |||
| 00f4904b8c | |||
| c8b913b55a | |||
| 914d34c6f5 | |||
| 6eb201198e | |||
| d088a6d161 | |||
| 3a1268e97b | |||
| 1c27ed7bf0 | |||
| 2be5c74101 | |||
| ed34e26e80 | |||
| 1cc5348270 | |||
| f0f0469b1f | |||
| e9ff030b4b | |||
| 84def9c3e3 | |||
| 08daa6fcfc | |||
| cd2d68d1b4 | |||
| c480c03c31 | |||
| 00efd1ce16 | |||
| 1869db7615 | |||
| a9e06b7a3b | |||
| 223559e44c | |||
| 627fd595da | |||
| 56a4f8a51e | |||
| c17dc371da | |||
| 5e17847717 | |||
| 0ec26c282b | |||
| 570ffcf818 | |||
| 5118256a1b | |||
| d851692c0d | |||
| a5a8560640 | |||
| 9518ef9f07 | |||
| f05cfb1c11 | |||
| 0668335a5d | |||
| d40e6b4992 | |||
| 7d51ff3a85 | |||
| eec5e8c887 | |||
| b979748679 | |||
| 71ded92b53 | |||
| 1ae030a256 | |||
| b0e3a8482c | |||
| f44c6cb2e3 | |||
| a0d3eb3a52 | |||
| e8aed23e1a | |||
| 7300cc58c3 | |||
| 045d362e93 | |||
| eba46cd587 | |||
| 31b87e7dd7 | |||
| 7539a1cc93 | |||
| 109816199f | |||
| 1d56f85e6d | |||
| ffcecb2d6c | |||
| e598bf502f | |||
| 918436f2cc | |||
| 33e33b4be9 | |||
| c342eb5d81 | |||
| 3a178c5ab8 | |||
| 166c4385a9 | |||
| 9a67bf3976 | |||
| 9c61a8475c | |||
| 7b5cbb8629 | |||
| 1e813d506f | |||
| 43a95a7d5a | |||
| 787a1e13c0 | |||
| 6abeae35f8 | |||
| cb94158bd0 | |||
| cf0924959c | |||
| 2ad8627215 | |||
| c2429a0269 | |||
| fd35207f2f | |||
| a9e318136f | |||
| 5f46156907 | |||
| ee7928dda8 | |||
| b39634d4fa | |||
| 23cd7c9e60 | |||
| 9ba1cacdaf | |||
| cc5d47e546 | |||
| 4ba6ef8f0d | |||
| 9003dc5841 | |||
| ba2b2c63a4 | |||
| 4ada912f98 | |||
| 9a10859109 | |||
| 5131c82b75 | |||
| e590c08095 | |||
| e7dcd58314 |
@@ -1,14 +1,58 @@
|
||||
/venv
|
||||
/__pycache__
|
||||
/vpn_users.db
|
||||
/config.py
|
||||
/database.db
|
||||
/bot_old.py
|
||||
/bot_old_2.py
|
||||
/database.db
|
||||
/backup_pg.sh
|
||||
/config copy.py
|
||||
/docker-compose.yml
|
||||
__pycache__
|
||||
/handlers/texts.py
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.env/
|
||||
.venv/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Logs and databases
|
||||
*.log
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Sensitive configuration files
|
||||
config.py
|
||||
config.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Specific project files
|
||||
vpn_users.db
|
||||
database.db
|
||||
bot_old.py
|
||||
bot_old_2.py
|
||||
backup_pg.sh
|
||||
docker-compose.yml
|
||||
config copy.py
|
||||
handlers/texts.py
|
||||
|
||||
# Miscellaneous
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
|
||||
setup.py
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client locales && \
|
||||
pip install --upgrade pip && pip install -r requirements.txt && \
|
||||
sed -i '/ru_RU.UTF-8/s/^# //g' /etc/locale.gen && \
|
||||
locale-gen ru_RU.UTF-8
|
||||
|
||||
# Устанавливаем переменные окружения
|
||||
ENV LANG=ru_RU.UTF-8
|
||||
ENV LANGUAGE=ru_RU:ru
|
||||
ENV LC_ALL=ru_RU.UTF-8
|
||||
|
||||
COPY . .
|
||||
|
||||
# Замена DATABASE_URL на значение из переменной окружения
|
||||
RUN sed -i "s|DATABASE_URL = .*|DATABASE_URL = '${DATABASE_URL}'|" config.py
|
||||
# dosker-compose в полной версии бота
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,330 @@
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||
License © Vladless, 2024.
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||
License"). To the extent this Public License may be interpreted as a
|
||||
contract, You are granted the Licensed Rights in consideration of Your
|
||||
acceptance of these terms and conditions, and the Licensor grants You
|
||||
such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and
|
||||
conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
d. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
i. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
j. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
k. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
l. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
@@ -0,0 +1,6 @@
|
||||
formatting:
|
||||
@echo "Running Ruff..." && ruff check . --fix
|
||||
@echo "Running Ruff format..." && ruff format .
|
||||
|
||||
lint:
|
||||
@echo "Running Ruff checks..." && ruff check .
|
||||
@@ -1,15 +1,31 @@
|
||||
# 🚀 SoloBot
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
*релиз 1.4 — полная доработка работы с ключами.
|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
|
||||
Три версии — море возможностей:
|
||||
|
||||
- [v1.4](https://github.com/Vladless/Solo_bot/releases/tag/v1.4) — бот для продажи ключей VLESS:
|
||||
|
||||

|
||||
|
||||
- [v2.3.1](https://github.com/Vladless/Solo_bot/releases/tag/v2.3.1) — стабильная версия подписок вместо ключей с
|
||||
кнопками автодобавления в приложение:
|
||||
|
||||

|
||||
|
||||
- [v3.1](https://github.com/Vladless/Solo_bot/releases/tag/v3.1) — версия бота со значительным расширением возможностей.
|
||||
|
||||
[Последние актуальные релизы.](https://github.com/Vladless/Solo_bot/releases)
|
||||
Если вам не хватает функций — направьте их в [issue](https://github.com/Vladless/Solo_bot/issues), мы реализуем.
|
||||
|
||||
## 📋 Оглавление
|
||||
1. [Описание](#описание)
|
||||
2. [Стек технологий](#стек-технологий)
|
||||
3. [Установка](#установка)
|
||||
4. [Конфигурация](#конфигурация)
|
||||
5. [Запуск](#запуск)
|
||||
6. [Контакты](#контакты)
|
||||
|
||||
1. [Описание](#-описание)
|
||||
2. [Стек технологий](#-стек-технологий)
|
||||
3. [Установка](#-установка)
|
||||
4. [Конфигурация](#-конфигурация)
|
||||
5. [Запуск](#-запуск)
|
||||
6. [Контакты](#-solobot-в-telegram-и-полная-версия)
|
||||
|
||||
---
|
||||
|
||||
@@ -17,44 +33,47 @@
|
||||
|
||||
SoloBot реализует множество функций, включая:
|
||||
|
||||
|
||||
- **Выдача ключей на пробный период**.
|
||||
- **Выдача ключей на длительные периоды** (1 месяц, 3 месяца, полгода, год).
|
||||
- **Выдача подписок на различные периоды** (1 месяц, 3 месяца, полгода, год).
|
||||
- **Использование тестового периода подписки.**
|
||||
- **Продление ключей** на указанные периоды.
|
||||
- Полный контроль клиента над своими ключами:
|
||||
- Удаление ключей.
|
||||
- Продление ключей.
|
||||
- Просмотр информации о ключе (сервер, оставшееся время, сам ключ).
|
||||
- **Полный контроль** клиента над своими ключами:
|
||||
- **Просмотр** информации о ключе (сервер, оставшееся время, сам ключ).
|
||||
- **Продление** ключей.
|
||||
- **Удаление** ключей.
|
||||
- **Смена локации** (перемещение ключа между серверами).
|
||||
- Поддержка нескольких ключей для одного клиента (несколько устройств).
|
||||
- **Реферальная программа** с пригласительной ссылкой.
|
||||
- Доступ к **инструкциям**.
|
||||
- **Пополнение баланса** через сервис Юкасса.
|
||||
- **Поддержка нескольких ключей и устройств** для одного клиента.
|
||||
- **Реферальная программа** с уникальной ссылкой для приглашений.
|
||||
- Доступ к **инструкциям** для подключения к сервису.
|
||||
- Пополнение баланса:
|
||||
* **Юкасса** (Самозанятость и ИП)
|
||||
* **Юмани** (Физические Лица) ([TrackLine](https://github.com/TrackLine))
|
||||
* **FreeKassa** (Физические Лица)
|
||||
* **Robokassa** (Физические Лица)
|
||||
* **Cryptobot** (Криптовалюта) ([izzzzzi](https://github.com/izzzzzi))
|
||||
* **Звезды Telegram**
|
||||
- Периодические **бэкапы базы данных клиентов**.
|
||||
- Уведомления:
|
||||
- Произвольные сообщения через админку.
|
||||
- Уведомления о неиспользованных пробниках.
|
||||
- Уведомления о истекающих ключах (за сутки, за 6 часов и в момент истечения).
|
||||
- Произвольные сообщения через панель администратора.
|
||||
- Уведомления о неиспользованных пробниках.
|
||||
- Уведомления об истекающих ключах (за сутки, за 6 часов и в момент истечения).
|
||||
- **Чат поддержки** и канал для связи.
|
||||
- **Автоматическое продление ключа** при наличии достаточного баланса.
|
||||
- **Удобная админка прямо в боте**
|
||||
- **мультисерверность** добавляй сервера в конфига, и они автоматически будут в боте
|
||||
- **Удобная панель администратора прямо в боте**
|
||||
- Серверы:
|
||||
- **Мультисерверность** — добавление сервера прямо в админке
|
||||
- **Проверка на доступность** — бот следит за серверами и их откликом
|
||||
- **Балансировка** — бот выдает подписку на наименее загруженный сервер
|
||||
|
||||

|
||||
---
|
||||
|
||||
## 💻 Стек технологий
|
||||
|
||||
Проект использует следующие технологии:
|
||||
Основные технологии, используемые в проекте:
|
||||
|
||||
- **Python** версии 3.8 или выше.
|
||||
- **Git** для клонирования репозитория.
|
||||
- **Virtualenv** для создания виртуального окружения (рекомендуется).
|
||||
- **PostgreSQL** для хранения данных.
|
||||
- **Nginx** для работы с вебхуками.
|
||||
- **aiogram** (версия 3.13) для взаимодействия с Telegram API.
|
||||
- **youkassa** для обработки платежей.
|
||||
- **aiohttp** для API запросов к панелям.
|
||||
- **Python** версии 3.10 (3.12, для последних версий).
|
||||
- **PostgreSQL** — для хранения данных.
|
||||
- **aiogram** — для взаимодействия с Telegram API.
|
||||
- **aiohttp** — для API запросов к панелям.
|
||||
|
||||
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
|
||||
|
||||
@@ -71,7 +90,9 @@ git clone https://github.com/Vladless/Solo_bot.git
|
||||
cd solo_bot
|
||||
```
|
||||
|
||||
### 2️⃣ Шаг 2: Создание и активация виртуального окружения
|
||||
### 2️⃣ Шаг 2: Создание и подключение виртуального окружения
|
||||
|
||||
Создайте виртуальное окружение **Python** и перейдите в сессию:
|
||||
|
||||
```
|
||||
python3 -m venv venv
|
||||
@@ -80,6 +101,8 @@ source venv/bin/activate
|
||||
|
||||
### 3️⃣ Шаг 3: Установка зависимостей
|
||||
|
||||
Установите зависимости:
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
@@ -88,46 +111,44 @@ pip install -r requirements.txt
|
||||
|
||||
Для правильной работы вам нужно:
|
||||
|
||||
* установить и запустить postgresql, создать пользователя для работы с базой данных и выдать ему права
|
||||
* Настроить ваш сервер на работу с ботом, выпустить SSL сертификат для домена
|
||||
* Настроить вебхуки и пути до них в NGINX
|
||||
* Установить и запустить **PostgreSQL**, создать пользователя для работы с базой данных и выдать ему права
|
||||
* Настроить ваш сервер для работы с ботом, выпустить SSL сертификат для домена
|
||||
* Настроить вебхуки и указать пути до них через любой веб-сервер, например **nginx**
|
||||
|
||||
* Создать файл config.py в корневой папке проекта с вашими данными:
|
||||
|
||||
```
|
||||
API_TOKEN = "токен вашего бота телеграм"
|
||||
|
||||
API_TOKEN = токен вашего бота телеграм
|
||||
|
||||
ADMIN_USERNAME = логин от вашей панели x-ray
|
||||
ADMIN_PASSWORD = пароль от вашей панели x-ray
|
||||
ADMIN_USERNAME = "логин от вашей панели x-ray"
|
||||
ADMIN_PASSWORD = "пароль от вашей панели x-ray"
|
||||
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
|
||||
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
|
||||
AUTH_URL = f"{API_URL}/login/"
|
||||
DATABASE_URL = путь к вашей базе данных, имеет вид "postgresql://{user}:{password}@{Ip-adress}:{port}/{имя базы данных}"
|
||||
ADMIN_ID = ID телеграм профиля администратора
|
||||
CHANNEL_URL = ссылка на ваш телеграм канал
|
||||
YOOKASSA_SECRET_KEY = ваш ключ юкассы
|
||||
YOOKASSA_SHOP_ID = ваш шопайди
|
||||
WEBHOOK_HOST = адрес вашего сервера для вебхуков
|
||||
WEBHOOK_PATH = '/webhook/'
|
||||
WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}"
|
||||
SUPPORT_CHAT_URL = ваша ссылка на поддержку
|
||||
|
||||
```
|
||||
**Полная версия конфигурации и файл кастомизации доступны через поддержку нашего бота**
|
||||
|
||||
**Все описания в одном файле!** Удобно настроить бот под свой сервис изменив информацию и цены в одном месте
|
||||
**Мы высылаем детальный гайд и недостающие файлы в поддержке бота!**
|
||||
|
||||
### 🚀 Запуск
|
||||
|
||||
введите команду из виртуального окружения
|
||||
Введите команду из виртуального окружения `venv/bin/activate`
|
||||
|
||||
```
|
||||
python main.py
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### 🔗 SoloBot в Telegram и Полная версия
|
||||
|
||||
Попробуйте SoloBot прямо сейчас в Telegram [по этой ссылке](https://t.me/SoloNetVPN_bot).
|
||||
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
|
||||
|
||||
Вы также можете поддержать автора или купить полную версию бота с сопровождением по установке в поддержке бота!
|
||||
Полная пошаговая инстуркция по запуску бота предоставляется в поддержке бота
|
||||
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||

|
||||
|
||||
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
|
||||
и пароль от сайта, а также задать необходимые вопросы!
|
||||
|
||||
## Права на использование
|
||||
|
||||
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
|
||||
@@ -0,0 +1,102 @@
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
tg_id BIGINT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
payment_system TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'success',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keys
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
expiry_time BIGINT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT 'cluster1',
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
referrer_tg_id BIGINT NOT NULL,
|
||||
reward_issued BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupon_usages
|
||||
(
|
||||
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (coupon_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notification_type TEXT NOT NULL,
|
||||
PRIMARY KEY (tg_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT NOT NULL,
|
||||
inbound_id TEXT NOT NULL,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts
|
||||
(
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT,
|
||||
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
|
||||
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
@@ -1,70 +0,0 @@
|
||||
import json
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import SERVERS
|
||||
|
||||
session = None
|
||||
|
||||
async def login_with_credentials(server_id: str, username: str, password: str):
|
||||
global session
|
||||
session = aiohttp.ClientSession()
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
auth_url = f"{api_url}/login/"
|
||||
|
||||
data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
async with session.post(auth_url, json=data) as response:
|
||||
if response.status == 200:
|
||||
session.cookie_jar.update_cookies(response.cookies)
|
||||
return session
|
||||
else:
|
||||
raise Exception(f"Ошибка авторизации: {response.status}, {await response.text()}")
|
||||
|
||||
|
||||
async def get_clients(session, server_id):
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
async with session.get(f'{api_url}/panel/api/inbounds/list/') as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Ошибка при получении клиентов: {response.status}, {await response.text()}")
|
||||
|
||||
|
||||
async def link(session, server_id: str, client_id: str, email: str):
|
||||
"""
|
||||
Получение ссылки для подключения по ID клиента.
|
||||
:param server_id: str - идентификатор сервера
|
||||
:param client_id: str - идентификатор клиента
|
||||
:param email: str - электронная почта клиента
|
||||
:return: str - ссылка для подключения
|
||||
"""
|
||||
response = await get_clients(session, server_id)
|
||||
|
||||
if 'obj' not in response or len(response['obj']) == 0:
|
||||
raise Exception("Не удалось получить данные клиентов.")
|
||||
|
||||
inbounds = response['obj'][0]
|
||||
settings = json.loads(inbounds['settings'])
|
||||
|
||||
stream_settings = json.loads(inbounds['streamSettings'])
|
||||
tcp = stream_settings.get('network', 'tcp')
|
||||
reality = stream_settings.get('security', 'reality')
|
||||
flow = stream_settings.get('flow', 'xtls-rprx-vision')
|
||||
|
||||
val = (
|
||||
f"vless://{client_id}@{SERVERS[server_id]['DOMEN']}?type={tcp}&security={reality}&pbk={SERVERS[server_id]['PBK']}"
|
||||
f"&fp=chrome&sni={SERVERS[server_id]['SNI']}&sid={SERVERS[server_id]['SID']}=%2F&flow={flow}#{SERVERS[server_id]['PREFIX']}-{email}"
|
||||
)
|
||||
return val
|
||||
|
||||
async def link_subscription(email, server_id):
|
||||
server = SERVERS.get(server_id)
|
||||
if server:
|
||||
subscription_url = f"{server['SUBSCRIPTION']}/{email}"
|
||||
return subscription_url
|
||||
else:
|
||||
raise ValueError(f"Server '{server_id}' not found in configuration.")
|
||||
@@ -1,48 +1,100 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
from config import ADMIN_ID, DB_NAME, DB_PASSWORD, DB_USER, BACK_DIR
|
||||
|
||||
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def backup_database():
|
||||
from bot import bot
|
||||
|
||||
try:
|
||||
if backup_file_path := _create_database_backup():
|
||||
await _send_backup_to_admin(bot, backup_file_path)
|
||||
_cleanup_old_backups()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
|
||||
|
||||
|
||||
def _create_database_backup():
|
||||
USER = DB_USER
|
||||
HOST = "localhost"
|
||||
BACKUP_DIR = BACK_DIR
|
||||
DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
|
||||
|
||||
os.environ['PGPASSWORD'] = DB_PASSWORD
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['pg_dump', '-U', USER, '-h', HOST, '-F', 'c', '-f', BACKUP_FILE, DB_NAME],
|
||||
check=True
|
||||
[
|
||||
"pg_dump",
|
||||
"-U",
|
||||
USER,
|
||||
"-h",
|
||||
HOST,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
BACKUP_FILE,
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
logging.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
|
||||
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
|
||||
return BACKUP_FILE
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Ошибка при создании бэкапа базы данных: {e}")
|
||||
return
|
||||
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
|
||||
return None
|
||||
finally:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
|
||||
async def _send_backup_to_admin(bot, backup_file_path):
|
||||
try:
|
||||
with open(BACKUP_FILE, 'rb') as backup_file:
|
||||
backup_input_file = BufferedInputFile(backup_file.read(), filename=os.path.basename(BACKUP_FILE))
|
||||
await bot.send_document(ADMIN_ID, backup_input_file)
|
||||
logging.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
|
||||
with open(backup_file_path, "rb") as backup_file:
|
||||
backup_input_file = BufferedInputFile(
|
||||
backup_file.read(), filename=os.path.basename(backup_file_path)
|
||||
)
|
||||
admin_ids: int | list[int] = ADMIN_ID
|
||||
if isinstance(admin_ids, list):
|
||||
for id in admin_ids:
|
||||
await bot.send_document(id, backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {id}")
|
||||
else:
|
||||
await bot.send_document(admin_ids, backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
|
||||
|
||||
def _cleanup_old_backups():
|
||||
try:
|
||||
subprocess.run(
|
||||
['find', BACKUP_DIR, '-type', 'f', '-name', '*.sql', '-mtime', '+7', '-exec', 'rm', '{}', ';'],
|
||||
check=True
|
||||
[
|
||||
"find",
|
||||
BACK_DIR,
|
||||
"-type",
|
||||
"f",
|
||||
"-name",
|
||||
"*.sql",
|
||||
"-mtime",
|
||||
"+3",
|
||||
"-exec",
|
||||
"rm",
|
||||
"{}",
|
||||
";",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
logging.info("Старые бэкапы удалены.")
|
||||
logger.info("Старые бэкапы удалены.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
|
||||
del os.environ['PGPASSWORD']
|
||||
|
||||
async def create_backup_and_send_to_admins(xui):
|
||||
await xui.login()
|
||||
await xui.database.export()
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
from aiogram import Bot, Dispatcher, Router
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import ErrorEvent
|
||||
|
||||
from config import API_TOKEN
|
||||
from logger import logger
|
||||
from middlewares.admin import AdminMiddleware
|
||||
from middlewares.database import DatabaseMiddleware
|
||||
from middlewares.delete import DeleteMessageMiddleware
|
||||
from middlewares.logging import LoggingMiddleware
|
||||
from middlewares.user import UserMiddleware
|
||||
|
||||
bot = Bot(token=API_TOKEN)
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
router = Router()
|
||||
|
||||
from handlers.admin import admin, admin_panel, user_editor
|
||||
from handlers.keys import key_management, keys
|
||||
from handlers import (notifications, pay,
|
||||
profile, start, commands)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
dp.include_router(admin.router)
|
||||
dp.include_router(admin_panel.router)
|
||||
dp.include_router(user_editor.router)
|
||||
dp.include_router(commands.router)
|
||||
dp.include_router(start.router)
|
||||
dp.include_router(profile.router)
|
||||
dp.include_router(keys.router)
|
||||
dp.include_router(key_management.router)
|
||||
dp.include_router(pay.router)
|
||||
dp.include_router(notifications.router)
|
||||
dp.message.middleware(AdminMiddleware())
|
||||
dp.callback_query.middleware(AdminMiddleware())
|
||||
dp.message.middleware(UserMiddleware())
|
||||
dp.callback_query.middleware(UserMiddleware())
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware())
|
||||
dp.callback_query.middleware(DatabaseMiddleware())
|
||||
|
||||
dp.message.outer_middleware(DeleteMessageMiddleware())
|
||||
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
|
||||
|
||||
|
||||
@dp.error()
|
||||
async def error_handler(event: ErrorEvent):
|
||||
logger.error(
|
||||
"Ошибка в боте:\n"
|
||||
f"Исключение: {event.exception}\n"
|
||||
f"Тип: {type(event.exception)}\n"
|
||||
f"Update: {event.update}\n"
|
||||
f"Трассировка:\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
@@ -1,177 +1,115 @@
|
||||
import json
|
||||
import py3xui
|
||||
|
||||
from config import SERVERS
|
||||
from config import LIMIT_IP
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def add_client(session, server_id: str, client_id: str, email: str, tg_id: str, limit_ip: int, total_gb: int, expiry_time: int, enable: bool, flow: str):
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
url = f'{api_url}/panel/api/inbounds/addClient'
|
||||
|
||||
email = email.lower()
|
||||
|
||||
client_data = {
|
||||
"id": client_id,
|
||||
"alterId": 0,
|
||||
"email": email,
|
||||
"limitIp": limit_ip,
|
||||
"totalGB": total_gb,
|
||||
"expiryTime": expiry_time,
|
||||
"enable": enable,
|
||||
"tgId": tg_id,
|
||||
"subId": email,
|
||||
"flow": flow,
|
||||
}
|
||||
|
||||
settings = json.dumps({"clients": [client_data]})
|
||||
|
||||
data = {
|
||||
"id": 1,
|
||||
"settings": settings
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async with session.post(url, json=data, headers=headers) as response:
|
||||
print(f"Запрос на добавление клиента: {data}")
|
||||
print(f"Статус ответа: {response.status}")
|
||||
response_text = await response.text()
|
||||
print(f"Ответ от сервера: {response_text}")
|
||||
|
||||
if response.status == 200:
|
||||
print(f"Клиент добавлен: email={email}")
|
||||
return await response.json()
|
||||
else:
|
||||
print(f"Ошибка при добавлении клиента: {response.status}, {response_text}")
|
||||
return None
|
||||
|
||||
|
||||
async def extend_client_key(session, server_id: str, tg_id, client_id, email: str, new_expiry_time: int) -> bool:
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
|
||||
async with session.get(f"{api_url}/panel/api/inbounds/getClientTraffics/{email}") as response:
|
||||
print(f"GET {response.url} Status: {response.status}")
|
||||
response_text = await response.text()
|
||||
print(f"GET Response: {response_text}")
|
||||
|
||||
if response.status != 200:
|
||||
print(f"Ошибка при получении данных клиента: {response.status} - {response_text}")
|
||||
return False
|
||||
|
||||
client_data = (await response.json()).get("obj", {})
|
||||
print(client_data)
|
||||
|
||||
if not client_data:
|
||||
print("Не удалось получить данные клиента.")
|
||||
return False
|
||||
|
||||
current_expiry_time = client_data.get('expiryTime', 0)
|
||||
|
||||
if current_expiry_time == 0:
|
||||
current_expiry_time = new_expiry_time
|
||||
|
||||
updated_expiry_time = max(current_expiry_time, new_expiry_time)
|
||||
|
||||
payload = {
|
||||
"id": 1,
|
||||
"settings": json.dumps({
|
||||
"clients": [
|
||||
{
|
||||
"id": client_id,
|
||||
"alterId": 0,
|
||||
"email": email.lower(),
|
||||
"limitIp": 2,
|
||||
"totalGB": 0,
|
||||
"expiryTime": updated_expiry_time,
|
||||
"enable": True,
|
||||
"tgId": tg_id,
|
||||
"subId": email,
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(f"{api_url}/panel/api/inbounds/updateClient/{client_id}", json=payload, headers=headers) as response:
|
||||
print(f"POST {response.url} Status: {response.status}")
|
||||
print(f"POST Request Data: {json.dumps(payload, indent=2)}")
|
||||
response_text = await response.text()
|
||||
print(f"POST Response: {response_text}")
|
||||
|
||||
if response.status == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"Ошибка при продлении ключа: {response.status} - {response_text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Ошибка запроса: {e}")
|
||||
return False
|
||||
|
||||
async def extend_client_key_admin(session, server_id: str, tg_id, client_id: str, email: str, new_expiry_time: int) -> bool:
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
|
||||
payload = {
|
||||
"id": 1,
|
||||
"settings": json.dumps({
|
||||
"clients": [
|
||||
{
|
||||
"id": client_id,
|
||||
"alterId": 0,
|
||||
"email": email.lower(),
|
||||
"limitIp": 2,
|
||||
"totalGB": 0,
|
||||
"expiryTime": new_expiry_time,
|
||||
"enable": True,
|
||||
"tgId": tg_id,
|
||||
"subId": email,
|
||||
"flow": "xtls-rprx-vision"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
async def add_client(
|
||||
xui,
|
||||
client_id: str,
|
||||
email: str,
|
||||
tg_id: str,
|
||||
limit_ip: int,
|
||||
total_gb: int,
|
||||
expiry_time: int,
|
||||
enable: bool,
|
||||
flow: str,
|
||||
inbound_id: int,
|
||||
):
|
||||
"""
|
||||
Adds a client to the server via 3x-ui.
|
||||
"""
|
||||
try:
|
||||
async with session.post(f"{api_url}/panel/api/inbounds/updateClient/{client_id}", json=payload, headers=headers) as response:
|
||||
print(f"POST {response.url} Status: {response.status}")
|
||||
print(f"POST Request Data: {json.dumps(payload, indent=2)}")
|
||||
response_text = await response.text()
|
||||
print(f"POST Response: {response_text}")
|
||||
|
||||
if response.status == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"Ошибка при продлении ключа: {response.status} - {response_text}")
|
||||
return False
|
||||
await xui.login()
|
||||
|
||||
client = py3xui.Client(
|
||||
id=client_id,
|
||||
email=email.lower(),
|
||||
limit_ip=limit_ip,
|
||||
total_gb=total_gb,
|
||||
expiry_time=expiry_time,
|
||||
enable=enable,
|
||||
tg_id=tg_id,
|
||||
sub_id=email,
|
||||
flow=flow,
|
||||
)
|
||||
|
||||
response = await xui.client.add(inbound_id, [client])
|
||||
|
||||
logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.")
|
||||
|
||||
return response if response else {"status": "failed"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка запроса: {e}")
|
||||
return False
|
||||
logger.error(f"Ошибка при добавлении клиента {email}: {e}")
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
async def delete_client(session, server_id: str, client_id: str) -> bool:
|
||||
api_url = SERVERS[server_id]['API_URL']
|
||||
url = f"{api_url}/panel/api/inbounds/1/delClient/{client_id}"
|
||||
headers = {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
async def extend_client_key(
|
||||
xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int
|
||||
):
|
||||
"""
|
||||
Функция для обновления срока действия ключа клиента по email.
|
||||
"""
|
||||
await xui.login()
|
||||
try:
|
||||
async with session.post(url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
else:
|
||||
print(f"Ошибка при удалении клиента: {response.status} - {await response.text()}")
|
||||
return False
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} не найден.")
|
||||
return
|
||||
|
||||
if not client.id:
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}"
|
||||
)
|
||||
|
||||
client.id = client_id
|
||||
client.expiry_time = new_expiry_time
|
||||
client.flow = "xtls-rprx-vision"
|
||||
client.sub_id = email
|
||||
client.total_gb = total_gb
|
||||
client.enable = True
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
|
||||
await xui.client.update(client.id, client)
|
||||
await xui.client.reset_stats(inbound_id, email)
|
||||
logger.info(
|
||||
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка запроса: {e}")
|
||||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||||
|
||||
|
||||
async def delete_client(
|
||||
xui,
|
||||
inbound_id: int,
|
||||
email: str,
|
||||
client_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Функция для удаления клиента с сервера 3x-ui.
|
||||
Возвращает True при успешном удалении, иначе False.
|
||||
"""
|
||||
await xui.login()
|
||||
try:
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||||
return False
|
||||
|
||||
client.id = client_id
|
||||
|
||||
await xui.client.delete(inbound_id, client.id)
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
from aiogram.filters import BaseFilter
|
||||
from aiogram.types import Message
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
class IsAdminFilter(BaseFilter):
|
||||
async def __call__(self, message: Message) -> bool:
|
||||
try:
|
||||
admin_ids: int | list[int] = ADMIN_ID
|
||||
if isinstance(admin_ids, list):
|
||||
return message.from_user.id in admin_ids
|
||||
return message.from_user.id == admin_ids
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,29 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .admin import router as admin_router
|
||||
from .coupons import router as coupons_router
|
||||
from .donate import router as donate_router
|
||||
from .instructions import router as instructions_router
|
||||
from .keys import router as keys_router
|
||||
from .notifications import router as notifications_router
|
||||
from .pay import router as pay_router
|
||||
from .payments import router as payments_router
|
||||
from .profile import router as profile_router
|
||||
from .start import router as start_router
|
||||
|
||||
router = Router(name="handlers_main_router")
|
||||
|
||||
router.include_routers(
|
||||
start_router,
|
||||
profile_router,
|
||||
pay_router,
|
||||
donate_router,
|
||||
coupons_router,
|
||||
notifications_router,
|
||||
payments_router,
|
||||
keys_router,
|
||||
instructions_router,
|
||||
admin_router,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .admin_coupons import router as coupons_router
|
||||
from .admin_panel import router as panel_router
|
||||
from .admin_servers import router as servers_router
|
||||
from .admin_user_editor import router as user_editor_router
|
||||
|
||||
router = Router(name="admins_main_router")
|
||||
|
||||
router.include_routers(
|
||||
panel_router,
|
||||
servers_router,
|
||||
coupons_router,
|
||||
user_editor_router,
|
||||
)
|
||||
@@ -1,82 +0,0 @@
|
||||
from aiogram import Router, types
|
||||
from aiogram.filters import Command
|
||||
from database import add_balance_to_client, check_connection_exists, update_key_expiry, get_client_id_by_email, get_tg_id_by_client_id
|
||||
from config import ADMIN_ID, DATABASE_URL, ADMIN_PASSWORD, ADMIN_USERNAME
|
||||
from datetime import datetime
|
||||
import asyncpg
|
||||
from auth import login_with_credentials
|
||||
from client import extend_client_key_admin
|
||||
|
||||
router = Router()
|
||||
|
||||
@router.message(Command('add_balance'))
|
||||
async def cmd_add_balance(message: types.Message):
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.reply("У вас нет доступа к этой команде.")
|
||||
return
|
||||
|
||||
try:
|
||||
_, client_id, amount = message.text.split()
|
||||
amount = float(amount)
|
||||
|
||||
if not await check_connection_exists(int(client_id)):
|
||||
await message.reply(f"Клиент с ID {client_id} не найден.")
|
||||
return
|
||||
|
||||
await add_balance_to_client(int(client_id), amount)
|
||||
await message.reply(f"Баланс клиента {client_id} увеличен на {amount} у.е.")
|
||||
except ValueError:
|
||||
await message.reply("Пожалуйста, используйте формат: /add_balance <client_id> <amount>")
|
||||
except Exception as e:
|
||||
await message.reply(f"Произошла ошибка: {e}")
|
||||
|
||||
@router.message(Command('update_key_expiry'))
|
||||
async def cmd_update_key_expiry(message: types.Message):
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.reply("У вас нет доступа к этой команде.")
|
||||
return
|
||||
|
||||
try:
|
||||
parts = message.text.split(maxsplit=2)
|
||||
if len(parts) != 3:
|
||||
await message.reply("Пожалуйста, используйте формат: /update_key_expiry <email> <expiry_time(YYYY-MM-DD HH:MM:SS)>")
|
||||
return
|
||||
|
||||
_, email, expiry_time_str = parts
|
||||
expiry_time = int(datetime.strptime(expiry_time_str, '%Y-%m-%d %H:%M:%S').timestamp() * 1000)
|
||||
|
||||
client_id = await get_client_id_by_email(email)
|
||||
if client_id is None:
|
||||
await message.reply(f"Клиент с email {email} не найден.")
|
||||
return
|
||||
|
||||
await update_key_expiry(client_id, expiry_time)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT server_id FROM keys WHERE client_id = $1', client_id)
|
||||
if not record:
|
||||
await message.reply("Клиент не найден в базе данных.")
|
||||
return
|
||||
|
||||
server_id = record['server_id']
|
||||
tg_id = await get_tg_id_by_client_id(client_id)
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
|
||||
print(f"Попытка обновить панель для server_id: {server_id}, tg_id: {tg_id}, client_id: {client_id}, email: {email}, expiryTime: {expiry_time}")
|
||||
|
||||
success = await extend_client_key_admin(session, server_id, tg_id, client_id, email, expiry_time)
|
||||
|
||||
print(f"Статус обновления панели: {'Успешно' if success else 'Не удалось'}")
|
||||
if success:
|
||||
await message.reply(f"Время истечения ключа для клиента {client_id} ({email}) обновлено и синхронизировано с панелью.")
|
||||
else:
|
||||
await message.reply(f"Время истечения ключа для клиента {client_id} ({email}) обновлено, но не удалось синхронизировать с панелью.")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
except ValueError:
|
||||
await message.reply("Пожалуйста, используйте формат: /update_key_expiry <email> <expiry_time(YYYY-MM-DD HH:MM:SS)>")
|
||||
except Exception as e:
|
||||
await message.reply(f"Произошла ошибка: {e}")
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database import create_coupon, delete_coupon_from_db, get_all_coupons
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
|
||||
class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_data = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
|
||||
async def show_coupon_management_menu(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Создать купон", callback_data="create_coupon")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"🛠 Меню управления купонами:", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupons", IsAdminFilter())
|
||||
async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
|
||||
try:
|
||||
coupons = await get_all_coupons(session)
|
||||
|
||||
if not coupons:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
"❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for coupon in coupons:
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n\n"
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"❌ Удалить {coupon['code']}",
|
||||
callback_data=f"delete_coupon_{coupon['code']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
coupon_list, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении списка купонов: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
|
||||
async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
|
||||
coupon_code = callback_query.data[len("delete_coupon_") :]
|
||||
|
||||
try:
|
||||
result = await delete_coupon_from_db(coupon_code, session)
|
||||
|
||||
if result:
|
||||
await show_coupon_list(callback_query, session)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.",
|
||||
)
|
||||
await show_coupon_list(callback_query, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении купона: {e}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "create_coupon", IsAdminFilter())
|
||||
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
|
||||
async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
|
||||
parts = text.split()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
|
||||
|
||||
if len(parts) != 3:
|
||||
await message.answer(
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
coupon_amount = float(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"⚠️ <b>Проверьте правильность введенных данных!</b>\n"
|
||||
"💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
|
||||
|
||||
result_message = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан! 🎉\n"
|
||||
f"Сумма: <b>{coupon_amount} рублей</b> 💰\n"
|
||||
f"Лимит использования: <b>{usage_limit} раз</b> 🔢."
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
|
||||
)
|
||||
|
||||
await message.answer(result_message, reply_markup=builder.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
@@ -1,77 +1,391 @@
|
||||
from aiogram import Router, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
import asyncpg
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from backup import backup_database
|
||||
from bot import bot
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
displaying_user_info = State()
|
||||
waiting_for_restart_confirmation = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
@router.message(Command('admin'))
|
||||
async def handle_admin_command(message: types.Message):
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.reply("У вас нет доступа к этой команде.")
|
||||
return
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Статистика пользователей", callback_data="user_stats")],
|
||||
[InlineKeyboardButton(text="Редактор пользователей", callback_data="user_editor")]
|
||||
])
|
||||
await message.reply("Панель администратора", reply_markup=keyboard)
|
||||
@router.callback_query(F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
|
||||
await handle_admin_message(callback_query.message, state)
|
||||
|
||||
@router.callback_query(lambda c: c.data == "user_stats")
|
||||
async def user_stats_menu(callback_query: CallbackQuery):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
|
||||
@router.message(Command("admin"), F.data == "admin", IsAdminFilter())
|
||||
async def handle_admin_message(message: types.Message, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
BOT_VERSION = "3.2.4-beta" # Укажите текущую версию бота
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика пользователей", callback_data="user_stats"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👥 Управление пользователями", callback_data="user_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🖥️ Управление серверами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎟️ Управление купонами", callback_data="coupons_editor"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Управление Ботом", callback_data="bot_management"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
await message.answer(
|
||||
f"🤖 Панель администратора\n\nВерсия бота: <b>{BOT_VERSION}</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "bot_management")
|
||||
async def handle_bot_management(callback_query: types.CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"🤖 Управление ботом",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "user_stats", IsAdminFilter())
|
||||
async def user_stats_menu(callback_query: CallbackQuery, session: Any):
|
||||
try:
|
||||
total_users = await conn.fetchval("SELECT COUNT(*) FROM connections")
|
||||
total_keys = await conn.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await conn.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
total_users = await session.fetchval("SELECT COUNT(*) FROM connections")
|
||||
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
|
||||
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
|
||||
|
||||
active_keys = await conn.fetchval("SELECT COUNT(*) FROM keys WHERE expiry_time > $1", int(datetime.utcnow().timestamp() * 1000))
|
||||
total_payments_today = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
|
||||
)
|
||||
total_payments_week = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
|
||||
)
|
||||
total_payments_month = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
|
||||
)
|
||||
total_payments_all_time = await session.fetchval(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM payments"
|
||||
)
|
||||
|
||||
active_keys = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
|
||||
int(datetime.utcnow().timestamp() * 1000),
|
||||
)
|
||||
expired_keys = total_keys - active_keys
|
||||
|
||||
stats_message = (
|
||||
f"🔹 <b>Общая статистика пользователей:</b>\n"
|
||||
f"• Всего пользователей: <b>{total_users}</b>\n"
|
||||
f"• Всего ключей: <b>{total_keys}</b>\n"
|
||||
f"• Всего рефералов: <b>{total_referrals}</b>\n"
|
||||
f"• Активные ключи: <b>{active_keys}</b>\n"
|
||||
f"• Истекшие ключи: <b>{expired_keys}</b>"
|
||||
f"📊 <b>Подробная статистика проекта:</b>\n\n"
|
||||
f"👥 Пользователи:\n"
|
||||
f" 🌐 Зарегистрировано: <b>{total_users}</b>\n"
|
||||
f" 🤝 Привлеченных рефералов: <b>{total_referrals}</b>\n\n"
|
||||
f"🔑 Ключи:\n"
|
||||
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
|
||||
f" ✅ Действующих: <b>{active_keys}</b>\n"
|
||||
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
|
||||
f"💰 Финансовая статистика:\n"
|
||||
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
|
||||
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
|
||||
f" 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
|
||||
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n"
|
||||
)
|
||||
|
||||
back_button = InlineKeyboardButton(text="Назад", callback_data="back_to_admin_menu")
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[back_button]
|
||||
])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📥 Выгрузить пользователей в CSV",
|
||||
callback_data="export_users_csv",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(stats_message, reply_markup=keyboard, parse_mode="HTML")
|
||||
finally:
|
||||
await conn.close()
|
||||
await callback_query.message.answer(
|
||||
stats_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in user_stats_menu: {e}")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data == "user_editor")
|
||||
@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
|
||||
async def export_users_csv(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
|
||||
try:
|
||||
users = await session.fetch(
|
||||
"""
|
||||
SELECT
|
||||
u.tg_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.language_code,
|
||||
u.is_bot,
|
||||
c.balance,
|
||||
c.trial
|
||||
FROM users u
|
||||
LEFT JOIN connections c ON u.tg_id = c.tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
if not users:
|
||||
await callback_query.message.answer(
|
||||
"📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n"
|
||||
for user in users:
|
||||
csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
|
||||
|
||||
file_name = BytesIO(csv_data.encode("utf-8-sig"))
|
||||
file_name.seek(0)
|
||||
|
||||
file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
file,
|
||||
caption="📥 Экспорт пользователей в CSV",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
file_name.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
|
||||
await callback_query.message.answer(
|
||||
"❗ Произошла ошибка при экспорте пользователей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
|
||||
async def export_payments_csv(callback_query: CallbackQuery, session: Any):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
|
||||
try:
|
||||
payments = await session.fetch(
|
||||
"""
|
||||
SELECT
|
||||
u.tg_id,
|
||||
u.username,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
p.amount,
|
||||
p.payment_system,
|
||||
p.status,
|
||||
p.created_at
|
||||
FROM users u
|
||||
JOIN payments p ON u.tg_id = p.tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
if not payments:
|
||||
await callback_query.message.answer(
|
||||
"📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
|
||||
for payment in payments:
|
||||
csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
|
||||
|
||||
file_name = BytesIO(csv_data.encode("utf-8-sig"))
|
||||
file_name.seek(0)
|
||||
|
||||
file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
|
||||
|
||||
await callback_query.message.answer_document(
|
||||
file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
|
||||
)
|
||||
file_name.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
|
||||
await callback_query.message.answer(
|
||||
"❗ Произошла ошибка при экспорте платежей.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
|
||||
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам 📢🌐:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_message)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
|
||||
async def process_message_to_all(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
text_message = message.text
|
||||
|
||||
try:
|
||||
tg_ids = await session.fetch("SELECT tg_id FROM connections")
|
||||
|
||||
total_users = len(tg_ids)
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record["tg_id"]
|
||||
try:
|
||||
await bot.send_message(chat_id=tg_id, text=text_message)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"📤 Рассылка завершена:\n"
|
||||
f"👥 Всего пользователей: {total_users}\n"
|
||||
f"✅ Успешно отправлено: {success_count}\n"
|
||||
f"❌ Не доставлено: {error_count}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "backups", IsAdminFilter())
|
||||
async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.answer(
|
||||
"💾 Инициализация резервного копирования базы данных..."
|
||||
)
|
||||
await backup_database()
|
||||
await callback_query.message.answer(
|
||||
"✅ Резервная копия успешно создана и отправлена администратору."
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
|
||||
async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
|
||||
await state.set_state(UserEditorState.waiting_for_restart_confirmation)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, перезапустить", callback_data="confirm_restart"
|
||||
),
|
||||
InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"🤔 Вы уверены, что хотите перезапустить бота?",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "confirm_restart",
|
||||
UserEditorState.waiting_for_restart_confirmation,
|
||||
IsAdminFilter(),
|
||||
)
|
||||
async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
await state.clear()
|
||||
await callback_query.message.answer(
|
||||
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
await callback_query.message.answer(
|
||||
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
except Exception as e:
|
||||
await callback_query.message.answer(
|
||||
f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "user_editor", IsAdminFilter())
|
||||
async def user_editor_menu(callback_query: CallbackQuery):
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Поиск по имени ключа", callback_data="search_by_key_name")],
|
||||
[InlineKeyboardButton(text="Поиск по tg_id", callback_data="search_by_tg_id")],
|
||||
[InlineKeyboardButton(text="Назад", callback_data="back_to_admin_menu")] # Back button
|
||||
])
|
||||
await callback_query.message.edit_text("Выберите метод поиска:", reply_markup=keyboard)
|
||||
|
||||
@router.callback_query(lambda c: c.data == "back_to_admin_menu")
|
||||
async def back_to_admin_menu(callback_query: CallbackQuery):
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Статистика пользователей", callback_data="user_stats")],
|
||||
[InlineKeyboardButton(text="Редактор пользователей", callback_data="user_editor")]
|
||||
])
|
||||
await callback_query.message.edit_text("Панель администратора", reply_markup=keyboard)
|
||||
|
||||
async def handle_error(tg_id, callback_query, message):
|
||||
await bot.edit_message_text(message, chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔍 Поиск по названию ключа",
|
||||
callback_data="search_by_key_name",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Поиск по Username", callback_data="search_by_username"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
|
||||
await callback_query.message.answer(
|
||||
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from backup import create_backup_and_send_to_admins
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
|
||||
from database import check_unique_server_name, get_servers_from_db
|
||||
from filters.admin import IsAdminFilter
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_cluster_name = State()
|
||||
waiting_for_api_url = State()
|
||||
waiting_for_inbound_id = State()
|
||||
waiting_for_server_name = State()
|
||||
waiting_for_subscription_url = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "servers_editor", IsAdminFilter())
|
||||
async def handle_servers_editor(callback_query: types.CallbackQuery):
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="➕ Добавить кластер", callback_data="add_cluster")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"<b>🔧 Управление кластерами</b>\n\n"
|
||||
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
|
||||
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
|
||||
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
|
||||
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "add_cluster", IsAdminFilter())
|
||||
async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.answer(
|
||||
"🔧 <b>Введите имя нового кластера:</b>\n\n"
|
||||
"<b>Имя кластера должно быть уникальным и на английском языке.</b>\n"
|
||||
"<i>Пример:</i> <code>cluster2</code> или <code>us_east_1</code>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(UserEditorState.waiting_for_cluster_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
|
||||
async def handle_cluster_name_input(message: types.Message, state: FSMContext):
|
||||
cluster_name = message.text.strip()
|
||||
|
||||
if cluster_name == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not cluster_name:
|
||||
await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
|
||||
return
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
|
||||
async def handle_server_name_input(message: types.Message, state: FSMContext):
|
||||
server_name = message.text.strip()
|
||||
|
||||
if server_name == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not server_name:
|
||||
await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
|
||||
return
|
||||
|
||||
server_unique = await check_unique_server_name(server_name)
|
||||
if not server_unique:
|
||||
await message.answer(
|
||||
"❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
await state.update_data(server_name=server_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"API URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port/panel_path</code>\n\n"
|
||||
"URL должен быть без слэша на конце!\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_api_url)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
|
||||
async def handle_api_url_input(message: types.Message, state: FSMContext):
|
||||
api_url = message.text.strip()
|
||||
|
||||
if api_url == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not api_url.startswith("https://"):
|
||||
await message.answer(
|
||||
"❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
api_url = api_url.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
await state.update_data(api_url=api_url)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Subscription URL должен быть в следующем формате:\n\n"
|
||||
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
|
||||
"URL должен быть без слэша и имени клиента на конце!\n"
|
||||
"Его можно увидеть в панели 3x-ui в информации о клиенте.",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_subscription_url)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
|
||||
async def handle_subscription_url_input(message: types.Message, state: FSMContext):
|
||||
subscription_url = message.text.strip()
|
||||
|
||||
if subscription_url == "❌ Отменить":
|
||||
await state.clear()
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
await message.answer(
|
||||
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not subscription_url.startswith("https://"):
|
||||
await message.answer(
|
||||
"❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
subscription_url = subscription_url.rstrip("/")
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
await state.update_data(subscription_url=subscription_url)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
|
||||
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_inbound_id)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
|
||||
async def handle_inbound_id_input(message: types.Message, state: FSMContext):
|
||||
inbound_id = message.text.strip()
|
||||
|
||||
if not inbound_id.isdigit():
|
||||
await message.answer(
|
||||
"❌ inbound_id должен быть числовым значением. Попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
user_data = await state.get_data()
|
||||
cluster_name = user_data.get("cluster_name")
|
||||
server_name = user_data.get("server_name")
|
||||
api_url = user_data.get("api_url")
|
||||
subscription_url = user_data.get("subscription_url")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
cluster_name,
|
||||
server_name,
|
||||
api_url,
|
||||
subscription_url,
|
||||
inbound_id,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад к кластерам", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
|
||||
async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for server in cluster_servers:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🌍 {server['server_name']}",
|
||||
callback_data=f"manage_server|{server['server_name']}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить сервер", callback_data=f"add_server|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Доступность серверов",
|
||||
callback_data=f"server_availability|{cluster_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💾 Создать бэкап кластера",
|
||||
callback_data=f"backup_cluster|{cluster_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🔧 Управление серверами для кластера {cluster_name}",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
|
||||
async def handle_check_server_availability(callback_query: types.CallbackQuery):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
if not cluster_servers:
|
||||
await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
|
||||
return
|
||||
|
||||
in_progress_message = await callback_query.message.answer(
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
|
||||
"Это может занять до 1 минуты, пожалуйста, подождите..."
|
||||
)
|
||||
|
||||
availability_message = (
|
||||
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
|
||||
)
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
online_users = len(await xui.client.online())
|
||||
availability_message += (
|
||||
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
availability_message += f"❌ {server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
await in_progress_message.edit_text(
|
||||
availability_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
|
||||
async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
server = None
|
||||
cluster_name = None
|
||||
for cluster, cluster_servers in servers.items():
|
||||
server = next(
|
||||
(s for s in cluster_servers if s["server_name"] == server_name), None
|
||||
)
|
||||
if server:
|
||||
cluster_name = cluster
|
||||
break
|
||||
|
||||
if server:
|
||||
api_url = server["api_url"]
|
||||
subscription_url = server["subscription_url"]
|
||||
inbound_id = server["inbound_id"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
|
||||
f"<b>📡 API URL:</b> {api_url}\n"
|
||||
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
|
||||
f"<b>🔑 Inbound ID:</b> {inbound_id}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("❌ Сервер не найден.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
|
||||
async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет", callback_data=f"manage_server|{server_name}"
|
||||
),
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
|
||||
async def handle_confirm_delete_server(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
server_name = callback_query.data.split("|")[1]
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM servers WHERE server_name = $1
|
||||
""",
|
||||
server_name,
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
|
||||
async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
await state.update_data(cluster_name=cluster_name)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
|
||||
"Рекомендуется указать локацию сервера в имени.\n\n"
|
||||
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(UserEditorState.waiting_for_server_name)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
|
||||
async def handle_backup_cluster(callback_query: types.CallbackQuery):
|
||||
cluster_name = callback_query.data.split("|")[1]
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
for server in cluster_servers:
|
||||
xui = AsyncApi(
|
||||
server["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
await create_backup_and_send_to_admins(xui)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
|
||||
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
@@ -0,0 +1,712 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import TOTAL_GB
|
||||
from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
|
||||
from filters.admin import IsAdminFilter
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
delete_key_from_db,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from handlers.utils import sanitize_key_name
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
waiting_for_username = State()
|
||||
displaying_user_info = State()
|
||||
waiting_for_new_balance = State()
|
||||
waiting_for_key_name = State()
|
||||
waiting_for_expiry_time = State()
|
||||
waiting_for_message_text = State()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
|
||||
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_tg_id)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_username", IsAdminFilter())
|
||||
async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Введите Username клиента:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_username)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
|
||||
async def handle_username_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
# Extract the username from a message text by removing leading '@' and the Telegram URL prefix
|
||||
username = message.text.strip().lstrip('@').replace('https://t.me/', '')
|
||||
user_record = await session.fetchrow(
|
||||
"SELECT tg_id FROM users WHERE username = $1", username
|
||||
)
|
||||
|
||||
if not user_record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🔍 Пользователь с указанным username не найден. 🚫",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
tg_id = user_record["tg_id"]
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
if balance is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🚫 Пользователь с указанным tg_id не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📝 Изменить баланс",
|
||||
callback_data=f"change_balance_{tg_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Восстановить пробник",
|
||||
callback_data=f"restore_trial_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✉️ Отправить сообщение",
|
||||
callback_data=f"send_message_{tg_id}"
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("send_message_"))
|
||||
async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.data.split("_")[2]
|
||||
await state.update_data(target_tg_id=tg_id)
|
||||
await callback_query.message.answer(
|
||||
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю."
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_message_text)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
|
||||
async def process_send_message(message: types.Message, state: FSMContext, bot: Bot):
|
||||
data = await state.get_data()
|
||||
target_tg_id = data.get("target_tg_id")
|
||||
|
||||
if not target_tg_id:
|
||||
await message.answer("🚫 Ошибка: ID пользователя не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
await bot.send_message(chat_id=target_tg_id, text=message.text)
|
||||
await message.answer("✅ Сообщение успешно отправлено.")
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось отправить сообщение: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
|
||||
async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
|
||||
tg_id = int(message.text)
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
if balance is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пользователь с указанным tg_id не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📝 Изменить баланс",
|
||||
callback_data=f"change_balance_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Восстановить пробник",
|
||||
callback_data=f"restore_trial_{tg_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="✉️ Отправить сообщение",
|
||||
callback_data=f"send_message_{tg_id}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
|
||||
async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
|
||||
await restore_trial(tg_id, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора", callback_data="admin"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
"✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
|
||||
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
await state.update_data(tg_id=tg_id)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_new_balance)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
|
||||
async def handle_new_balance_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
if not message.text.isdigit() or int(message.text) < 0:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, введите корректную сумму для изменения баланса.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
new_balance = int(message.text)
|
||||
user_data = await state.get_data()
|
||||
tg_id = user_data.get("tg_id")
|
||||
|
||||
await session.execute(
|
||||
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
|
||||
new_balance,
|
||||
tg_id,
|
||||
)
|
||||
|
||||
response_message = f"✅ Баланс успешно изменен на <b>{new_balance}</b>."
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в меню администратора",
|
||||
callback_data="admin",
|
||||
)
|
||||
)
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def get_key_details(email, session):
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
|
||||
FROM keys k
|
||||
JOIN connections c ON k.tg_id = c.tg_id
|
||||
WHERE k.email = $1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
|
||||
if not record:
|
||||
return None
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
cluster_name = "Неизвестный кластер"
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
if any(
|
||||
server["inbound_id"] == record["server_id"] for server in cluster_servers
|
||||
):
|
||||
cluster_name = cluster_name
|
||||
break
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(record["expiry_time"] / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>Ключ истек.</b>"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
return {
|
||||
"key": record["key"],
|
||||
"expiry_date": expiry_date.strftime("%d %B %Y года"),
|
||||
"days_left_message": days_left_message,
|
||||
"server_name": cluster_name,
|
||||
"balance": record["balance"],
|
||||
"tg_id": record["tg_id"],
|
||||
}
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
|
||||
async def process_key_edit(callback_query: CallbackQuery, session: Any):
|
||||
email = callback_query.data.split("_", 2)[2]
|
||||
key_details = await get_key_details(email, session)
|
||||
|
||||
if not key_details:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 <b>Информация о ключе не найдена.</b> 🚫",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
response_message = (
|
||||
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
|
||||
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
|
||||
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
|
||||
f"🌐 Кластер: <b>{key_details['server_name']}</b>"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="ℹ️ Получить информацию о юзере",
|
||||
callback_data=f"user_info|{key_details['tg_id']}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{email}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ",
|
||||
callback_data=f"delete_key_admin|{email}",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
|
||||
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔑 Введите имя ключа:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(UserEditorState.waiting_for_key_name)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
|
||||
async def handle_key_name_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
key_name = sanitize_key_name(message.text)
|
||||
key_details = await get_key_details(key_name, session)
|
||||
|
||||
if not key_details:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"🚫 Пользователь с указанным именем ключа не найден.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
response_message = (
|
||||
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
|
||||
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
|
||||
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
|
||||
f"🌐 Сервер: <b>{key_details['server_name']}</b>"
|
||||
)
|
||||
|
||||
key_buttons = InlineKeyboardBuilder()
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="ℹ️ Получить информацию о юзере",
|
||||
callback_data=f"user_info|{key_details['tg_id']}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Изменить время истечения",
|
||||
callback_data=f"change_expiry|{key_name}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить ключ",
|
||||
callback_data=f"delete_key_admin|{key_name}",
|
||||
)
|
||||
)
|
||||
key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
await message.answer(response_message, reply_markup=key_buttons.as_markup())
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
|
||||
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
email = callback_query.data.split("|")[1]
|
||||
await callback_query.message.answer(
|
||||
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:"
|
||||
)
|
||||
await state.update_data(email=email)
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
|
||||
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
|
||||
async def handle_expiry_time_input(
|
||||
message: types.Message, state: FSMContext, session: Any
|
||||
):
|
||||
user_data = await state.get_data()
|
||||
email = user_data.get("email")
|
||||
|
||||
if not email:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_time_str = message.text
|
||||
expiry_time = int(
|
||||
datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").timestamp() * 1000
|
||||
)
|
||||
|
||||
client_id = await get_client_id_by_email(email)
|
||||
if client_id is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
)
|
||||
await message.answer(
|
||||
f"🚫 Клиент с email {email} не найден. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
record = await session.fetchrow(
|
||||
"SELECT server_id FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
if not record:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
)
|
||||
await message.answer(
|
||||
"🚫 Клиент не найден в базе данных. 🔍",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
clusters = await get_servers_from_db()
|
||||
|
||||
async def update_key_on_all_servers():
|
||||
tasks = []
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for server in cluster_servers:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_name,
|
||||
email,
|
||||
client_id,
|
||||
expiry_time,
|
||||
total_gb=TOTAL_GB,
|
||||
)
|
||||
)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await update_key_on_all_servers()
|
||||
|
||||
await update_key_expiry(client_id, expiry_time)
|
||||
|
||||
response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
|
||||
await message.answer(response_message, reply_markup=builder.as_markup())
|
||||
except ValueError:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
|
||||
async def process_callback_delete_key(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
email = callback_query.data.split("|")[1]
|
||||
client_id = await session.fetchval(
|
||||
"SELECT client_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if client_id is None:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
await callback_query.message.answer(
|
||||
"🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"confirm_delete_admin|{client_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
|
||||
)
|
||||
await callback_query.message.answer(
|
||||
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
|
||||
async def process_callback_confirm_delete(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
record = await session.fetchrow(
|
||||
"SELECT email FROM keys WHERE client_id = $1", client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
response_message = "✅ Ключ успешно удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
|
||||
clusters = await get_servers_from_db()
|
||||
|
||||
async def delete_key_from_servers(email, client_id):
|
||||
tasks = []
|
||||
for cluster_name, cluster_servers in clusters.items():
|
||||
for server in cluster_servers:
|
||||
tasks.append(
|
||||
delete_key_from_cluster(cluster_name, email, client_id)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await delete_key_from_servers(email, client_id)
|
||||
await delete_key_from_db(client_id, session)
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
else:
|
||||
response_message = "🚫 Ключ не найден или уже удален."
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
|
||||
async def handle_user_info(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = int(callback_query.data.split("|")[1])
|
||||
username = await session.fetchval(
|
||||
"SELECT username FROM users WHERE tg_id = $1", tg_id
|
||||
)
|
||||
balance = await session.fetchval(
|
||||
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
|
||||
)
|
||||
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await session.fetchval(
|
||||
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for (email,) in key_records:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
|
||||
|
||||
user_info = (
|
||||
f"📊 Информация о пользователе:\n\n"
|
||||
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
|
||||
f"👤 Логин пользователя: <b>@{username}</b>\n"
|
||||
f"💰 Баланс: <b>{balance}</b>\n"
|
||||
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"🔑 Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
|
||||
await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
|
||||
async def confirm_delete_user(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[3])
|
||||
|
||||
confirmation_markup = InlineKeyboardMarkup(
|
||||
row_width=2,
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}")],
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
|
||||
],
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=confirmation_markup
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
|
||||
async def delete_user(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = int(callback_query.data.split("_")[2])
|
||||
|
||||
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
servers = await get_servers_from_db()
|
||||
for cluster_id, cluster in servers.items():
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
|
||||
try:
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await callback_query.message.answer(f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
|
||||
await callback_query.message.answer(
|
||||
f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
|
||||
)
|
||||
@@ -1,368 +0,0 @@
|
||||
from aiogram import Router, types, F
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
from config import DATABASE_URL, SERVERS
|
||||
import asyncpg
|
||||
from datetime import datetime
|
||||
from bot import bot
|
||||
from database import update_key_expiry, get_client_id_by_email, get_tg_id_by_client_id
|
||||
from config import DATABASE_URL, ADMIN_PASSWORD, ADMIN_USERNAME
|
||||
from datetime import datetime
|
||||
import asyncpg
|
||||
from auth import login_with_credentials
|
||||
from client import extend_client_key_admin
|
||||
from handlers.admin.admin_panel import back_to_admin_menu
|
||||
from client import delete_client
|
||||
|
||||
router = Router()
|
||||
|
||||
class UserEditorState(StatesGroup):
|
||||
waiting_for_tg_id = State()
|
||||
displaying_user_info = State()
|
||||
waiting_for_new_balance = State()
|
||||
waiting_for_key_name = State()
|
||||
waiting_for_expiry_time = State()
|
||||
|
||||
@router.callback_query(lambda c: c.data == "search_by_tg_id")
|
||||
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text("Введите tg_id клиента:")
|
||||
await state.set_state(UserEditorState.waiting_for_tg_id)
|
||||
|
||||
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit())
|
||||
async def handle_tg_id_input(message: types.Message, state: FSMContext):
|
||||
tg_id = int(message.text)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
balance = await conn.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
|
||||
key_records = await conn.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
|
||||
referral_count = await conn.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
|
||||
|
||||
if balance is None:
|
||||
await message.reply("Пользователь с указанным tg_id не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
key_buttons = [
|
||||
[InlineKeyboardButton(text=email, callback_data=f"edit_key_{email}")]
|
||||
for email, in key_records
|
||||
]
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
*key_buttons,
|
||||
[InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}")],
|
||||
[InlineKeyboardButton(text="Назад", callback_data="back_to_user_editor")]
|
||||
])
|
||||
|
||||
user_info = (
|
||||
f"Информация о пользователе:\n"
|
||||
f"Баланс: <b>{balance}</b>\n"
|
||||
f"Количество рефералов: <b>{referral_count}</b>\n"
|
||||
f"Ключи (для редактирования нажмите на ключ):"
|
||||
)
|
||||
await message.reply(user_info, reply_markup=keyboard, parse_mode="HTML")
|
||||
await state.set_state(UserEditorState.displaying_user_info)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('change_balance_'))
|
||||
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = int(callback_query.data.split('_')[2])
|
||||
await state.update_data(tg_id=tg_id)
|
||||
|
||||
await callback_query.message.edit_text("Введите новую сумму баланса:")
|
||||
await callback_query.answer()
|
||||
await state.set_state(UserEditorState.waiting_for_new_balance)
|
||||
|
||||
@router.message(UserEditorState.waiting_for_new_balance)
|
||||
async def handle_new_balance_input(message: types.Message, state: FSMContext):
|
||||
if not message.text.isdigit() or int(message.text) < 0:
|
||||
await message.reply("Пожалуйста, введите корректную сумму для изменения баланса.")
|
||||
return
|
||||
|
||||
new_balance = int(message.text)
|
||||
user_data = await state.get_data()
|
||||
tg_id = user_data.get('tg_id')
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
await conn.execute("UPDATE connections SET balance = $1 WHERE tg_id = $2", new_balance, tg_id)
|
||||
|
||||
response_message = f"Баланс успешно изменен на <b>{new_balance}</b>."
|
||||
|
||||
back_button = InlineKeyboardButton(text="Назад в меню админа", callback_data="back_to_user_editor")
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await message.reply(response_message, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('edit_key_'))
|
||||
async def process_key_edit(callback_query: CallbackQuery):
|
||||
email = callback_query.data.split('_', 2)[2]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('''
|
||||
SELECT k.key, k.expiry_time, k.server_id
|
||||
FROM keys k
|
||||
WHERE k.email = $1
|
||||
''', email)
|
||||
|
||||
if record:
|
||||
key = record['key']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
server_name = SERVERS.get(server_id, {}).get('name', 'Неизвестный сервер')
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>Ключ истек.</b>"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime('%d %B %Y года')
|
||||
|
||||
response_message = (
|
||||
f"Ключ: <pre>{key}</pre>\n"
|
||||
f"Дата истечения: <b>{formatted_expiry_date}</b>\n"
|
||||
f"{days_left_message}\n"
|
||||
f"Сервер: <b>{server_name}</b>"
|
||||
)
|
||||
|
||||
change_expiry_button = types.InlineKeyboardButton(text='⏳ Изменить время истечения', callback_data=f'change_expiry|{email}')
|
||||
delete_button = types.InlineKeyboardButton(text='❌ Удалить ключ', callback_data=f'delete_key_admin|{email}')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[change_expiry_button, delete_button],
|
||||
[InlineKeyboardButton(text="Назад", callback_data="back_to_user_editor")] # Кнопка "Назад"
|
||||
]
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(response_message, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await callback_query.message.edit_text("<b>Информация о ключе не найдена.</b>", parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(callback_query.from_user.id, callback_query, f"Ошибка при получении информации о ключе: {e}")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data == "search_by_key_name")
|
||||
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text("Введите имя ключа:")
|
||||
await state.set_state(UserEditorState.waiting_for_key_name)
|
||||
|
||||
@router.message(UserEditorState.waiting_for_key_name)
|
||||
async def handle_key_name_input(message: types.Message, state: FSMContext):
|
||||
key_name = message.text
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
user_records = await conn.fetch('''
|
||||
SELECT c.tg_id, c.balance, k.email, k.key, k.expiry_time, k.server_id
|
||||
FROM connections c
|
||||
JOIN keys k ON c.tg_id = k.tg_id
|
||||
WHERE k.email = $1
|
||||
''', key_name)
|
||||
|
||||
if not user_records:
|
||||
await message.reply("Пользователь с указанным именем ключа не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
response_messages = []
|
||||
key_buttons = []
|
||||
|
||||
for record in user_records:
|
||||
tg_id = record['tg_id']
|
||||
balance = record['balance']
|
||||
email = record['email']
|
||||
key = record['key']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
server_name = SERVERS.get(server_id, {}).get('name', 'Неизвестный сервер')
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000).strftime('%d %B %Y')
|
||||
|
||||
response_messages.append(
|
||||
f"Ключ: <pre>{key}</pre>\n"
|
||||
f"Дата истечения: <b>{expiry_date}</b>\n"
|
||||
f"Баланс пользователя: <b>{balance}</b>\n"
|
||||
f"Сервер: <b>{server_name}</b>"
|
||||
)
|
||||
|
||||
change_expiry_button = InlineKeyboardButton(text='⏳ Изменить время истечения', callback_data=f'change_expiry|{email}')
|
||||
delete_button = InlineKeyboardButton(text='❌ Удалить ключ', callback_data=f'delete_key_admin|{email}')
|
||||
|
||||
key_buttons.append([change_expiry_button, delete_button])
|
||||
|
||||
key_buttons.append([InlineKeyboardButton(text="Назад", callback_data="back_to_user_editor")])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=key_buttons)
|
||||
|
||||
await message.reply("\n".join(response_messages), reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await state.clear()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('change_expiry|'))
|
||||
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
|
||||
email = callback_query.data.split('|')[1]
|
||||
await callback_query.message.edit_text(
|
||||
f"Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.update_data(email=email)
|
||||
await state.set_state(UserEditorState.waiting_for_expiry_time)
|
||||
|
||||
@router.message(UserEditorState.waiting_for_expiry_time)
|
||||
async def handle_expiry_time_input(message: types.Message, state: FSMContext):
|
||||
user_data = await state.get_data()
|
||||
email = user_data.get('email')
|
||||
|
||||
if not email:
|
||||
await message.reply("Email не найден в состоянии.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
expiry_time_str = message.text
|
||||
expiry_time = int(datetime.strptime(expiry_time_str, '%Y-%m-%d %H:%M:%S').timestamp() * 1000)
|
||||
|
||||
client_id = await get_client_id_by_email(email)
|
||||
if client_id is None:
|
||||
await message.reply(f"Клиент с email {email} не найден.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_key_expiry(client_id, expiry_time)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT server_id FROM keys WHERE client_id = $1', client_id)
|
||||
if not record:
|
||||
await message.reply("Клиент не найден в базе данных.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
server_id = record['server_id']
|
||||
tg_id = await get_tg_id_by_client_id(client_id)
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
|
||||
print(f"Попытка обновить панель для server_id: {server_id}, tg_id: {tg_id}, client_id: {client_id}, email: {email}, expiryTime: {expiry_time}")
|
||||
|
||||
success = await extend_client_key_admin(session, server_id, tg_id, client_id, email, expiry_time)
|
||||
|
||||
print(f"Статус обновления панели: {'Успешно' if success else 'Не удалось'}")
|
||||
if success:
|
||||
response_message = (
|
||||
f"Время истечения ключа для клиента {client_id} ({email}) успешно обновлено и синхронизировано с панелью."
|
||||
)
|
||||
else:
|
||||
response_message = (
|
||||
f"Время истечения ключа для клиента {client_id} ({email}) обновлено, но не удалось синхронизировать с панелью."
|
||||
)
|
||||
|
||||
back_button = InlineKeyboardButton(text="Назад", callback_data="back_to_user_editor")
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await message.reply(response_message, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
except ValueError:
|
||||
await message.reply("Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.")
|
||||
except Exception as e:
|
||||
await message.reply(f"Произошла ошибка: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('delete_key_admin|'))
|
||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
email = callback_query.data.split('|')[1]
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
client_id = await conn.fetchval('SELECT client_id FROM keys WHERE email = $1', email)
|
||||
|
||||
if client_id is None:
|
||||
await bot.edit_message_text("Ключ не найден.", chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
return
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Да, удалить', callback_data=f'confirm_delete_admin|{client_id}')],
|
||||
[types.InlineKeyboardButton(text='❌ Нет, отменить', callback_data='view_keys')]
|
||||
])
|
||||
|
||||
await bot.edit_message_text("<b>Вы уверены, что хотите удалить ключ?</b>", chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=confirmation_keyboard, parse_mode="HTML")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('confirm_delete_admin|'))
|
||||
async def process_callback_confirm_delete(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split('|')[1]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT email, server_id FROM keys WHERE client_id = $1', client_id)
|
||||
|
||||
if record:
|
||||
email = record['email']
|
||||
server_id = record['server_id']
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success = await delete_client(session, server_id, client_id)
|
||||
|
||||
if success:
|
||||
await conn.execute('DELETE FROM keys WHERE client_id = $1', client_id)
|
||||
response_message = "Ключ был успешно удален."
|
||||
else:
|
||||
response_message = "Ошибка при удалении клиента через API."
|
||||
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
|
||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_keys')
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(f"Ошибка при удалении ключа: {e}", chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data == "back_to_user_editor")
|
||||
async def back_to_user_editor(callback_query: CallbackQuery):
|
||||
await back_to_admin_menu(callback_query)
|
||||
|
||||
async def handle_error(tg_id, callback_query, message):
|
||||
await bot.edit_message_text(message, chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode="HTML")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
GIFT = "🎁 Подарить подписку"
|
||||
MY_GIFTS = "🎁 Мои подарки"
|
||||
PROFILE = "👤 Личный кабинет"
|
||||
BACK = "🔙 Назад"
|
||||
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
|
||||
@@ -0,0 +1,8 @@
|
||||
ADD_SUB = "➕ Устройство"
|
||||
MY_SUBS = "📱 Мои устройства"
|
||||
PAYMENT = "💳 Пополнить баланс"
|
||||
INVITE = "👥 Пригласить"
|
||||
GIFTS = "🎁 Подарить"
|
||||
TARRIFS = "💡 Тарифы"
|
||||
INSTRUCTIONS = "📘 Инструкции"
|
||||
MAIN_MENU = "⬅️ Главное меню"
|
||||
@@ -0,0 +1,3 @@
|
||||
PAY = "Пополнить"
|
||||
BACK = "⬅️ Назад"
|
||||
CUSTOM_SUM = "💰 Ввести свою сумму"
|
||||
@@ -1,149 +0,0 @@
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
import asyncpg
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
from handlers.pay import ReplenishBalanceState, process_custom_amount_input
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.start import start_command
|
||||
from handlers.texts import TRIAL
|
||||
from handlers.admin.admin import cmd_add_balance
|
||||
from handlers.keys.key_management import handle_key_name_input
|
||||
from aiogram.types import Message
|
||||
|
||||
router = Router()
|
||||
|
||||
class Form(StatesGroup):
|
||||
waiting_for_server_selection = State()
|
||||
waiting_for_key_name = State()
|
||||
viewing_profile = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
@router.message(Command('backup'))
|
||||
async def backup_command(message: Message):
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||
return
|
||||
|
||||
from backup import backup_database
|
||||
await message.answer("Запускаю бэкап базы данных...")
|
||||
await backup_database()
|
||||
await message.answer("Бэкап завершен и отправлен админу.")
|
||||
|
||||
@router.message(Command('start'))
|
||||
async def handle_start(message: types.Message, state: FSMContext):
|
||||
await start_command(message)
|
||||
|
||||
@router.message(Command('add_balance'))
|
||||
async def handle_add_balance(message: types.Message, state: FSMContext):
|
||||
await cmd_add_balance(message)
|
||||
|
||||
@router.message(Command('menu'))
|
||||
async def handle_menu(message: types.Message, state: FSMContext):
|
||||
await start_command(message)
|
||||
|
||||
@router.message(Command('send_trial'))
|
||||
async def handle_send_trial_command(message: types.Message, state: FSMContext):
|
||||
# Проверка на администратора
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.reply("У вас нет доступа к этой команде.")
|
||||
return
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
records = await conn.fetch('''
|
||||
SELECT tg_id FROM connections WHERE trial = 0
|
||||
''')
|
||||
|
||||
if records:
|
||||
for record in records:
|
||||
tg_id = record['tg_id']
|
||||
trial_message = TRIAL
|
||||
try:
|
||||
await bot.send_message(chat_id=tg_id, text=trial_message)
|
||||
except Exception as e:
|
||||
if "Forbidden: bot was blocked by the user" in str(e):
|
||||
print(f"Бот заблокирован пользователем с tg_id: {tg_id}")
|
||||
else:
|
||||
print(f"Ошибка при отправке сообщения пользователю {tg_id}: {e}")
|
||||
|
||||
await message.answer("Сообщения о пробном периоде отправлены всем пользователям с не использованным ключом.")
|
||||
else:
|
||||
await message.answer("Нет пользователей с не использованными пробными ключами.")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"Ошибка при отправке сообщений: {e}")
|
||||
|
||||
@router.message(Command('send_to_all'))
|
||||
async def send_message_to_all_clients(message: types.Message, state: FSMContext):
|
||||
if message.from_user.id != ADMIN_ID:
|
||||
await message.answer("У вас нет прав для выполнения этой команды.")
|
||||
return
|
||||
|
||||
await message.answer("Введите текст сообщения, который вы хотите отправить всем клиентам:")
|
||||
await state.set_state(Form.waiting_for_message)
|
||||
|
||||
@router.message(Form.waiting_for_message)
|
||||
async def process_message_to_all(message: types.Message, state: FSMContext):
|
||||
text_message = message.text
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
tg_ids = await conn.fetch('SELECT tg_id FROM connections')
|
||||
|
||||
for record in tg_ids:
|
||||
tg_id = record['tg_id']
|
||||
try:
|
||||
await bot.send_message(chat_id=tg_id, text=text_message)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при отправке сообщения пользователю {tg_id}: {e}. Пропускаем этого пользователя.")
|
||||
|
||||
await message.answer("Сообщение было отправлено всем клиентам.")
|
||||
except Exception as e:
|
||||
print(f"Ошибка при подключении к базе данных: {e}")
|
||||
await message.answer("Произошла ошибка при отправке сообщения.")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await state.clear()
|
||||
|
||||
@router.message()
|
||||
async def handle_text(message: types.Message, state: FSMContext):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if message.text in ["/send_to_all"]:
|
||||
await send_message_to_all_clients(message, state)
|
||||
return
|
||||
|
||||
if message.text == "Мой профиль":
|
||||
callback_query = types.CallbackQuery(
|
||||
id="1",
|
||||
from_user=message.from_user,
|
||||
chat_instance='',
|
||||
data='view_profile',
|
||||
message=message
|
||||
)
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
return
|
||||
|
||||
if current_state == ReplenishBalanceState.entering_custom_amount.state:
|
||||
await process_custom_amount_input(message, state)
|
||||
return
|
||||
|
||||
if current_state == Form.waiting_for_key_name.state:
|
||||
await handle_key_name_input(message, state)
|
||||
return
|
||||
|
||||
if message.text == "/backup":
|
||||
await backup_command(message)
|
||||
return
|
||||
|
||||
elif current_state is None:
|
||||
await start_command(message)
|
||||
@@ -0,0 +1,97 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from database import update_balance
|
||||
|
||||
|
||||
class CouponActivationState(StatesGroup):
|
||||
waiting_for_coupon_code = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "activate_coupon")
|
||||
async def handle_activate_coupon(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"<b>🎫 Введите код купона:</b>\n\n"
|
||||
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(CouponActivationState.waiting_for_coupon_code)
|
||||
|
||||
|
||||
@router.message(CouponActivationState.waiting_for_coupon_code)
|
||||
async def process_coupon_code(message: types.Message, state: FSMContext, session: Any):
|
||||
coupon_code = message.text.strip()
|
||||
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await message.answer(
|
||||
activation_result, reply_markup=builder.as_markup(), parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
|
||||
coupon_record = await session.fetchrow(
|
||||
"""
|
||||
SELECT id, usage_limit, usage_count, is_used, amount
|
||||
FROM coupons
|
||||
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
|
||||
""",
|
||||
coupon_code,
|
||||
)
|
||||
|
||||
if not coupon_record:
|
||||
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
|
||||
|
||||
usage_exists = await session.fetchrow(
|
||||
"""
|
||||
SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2
|
||||
""",
|
||||
coupon_record["id"],
|
||||
user_id,
|
||||
)
|
||||
|
||||
if usage_exists:
|
||||
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
|
||||
|
||||
coupon_amount = coupon_record["amount"]
|
||||
|
||||
async with session.transaction():
|
||||
await session.execute(
|
||||
"""
|
||||
UPDATE coupons
|
||||
SET usage_count = usage_count + 1,
|
||||
is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END
|
||||
WHERE id = $1
|
||||
""",
|
||||
coupon_record["id"],
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
"""
|
||||
INSERT INTO coupon_usages (coupon_id, user_id, used_at)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
coupon_record["id"],
|
||||
user_id,
|
||||
datetime.utcnow(),
|
||||
)
|
||||
|
||||
await update_balance(user_id, coupon_amount)
|
||||
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
|
||||
@@ -0,0 +1,113 @@
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, LabeledPrice, PreCheckoutQuery
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import RUB_TO_XTR
|
||||
from logger import logger
|
||||
|
||||
|
||||
class DonateState(StatesGroup):
|
||||
entering_donate_amount = State()
|
||||
waiting_for_donate_confirmation = State()
|
||||
waiting_for_donate_payment = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "donate")
|
||||
async def process_donate(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await state.clear()
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести сумму доната",
|
||||
callback_data="enter_custom_donate_amount",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
text="🌟 Поддержите наш проект! 💪\n\n"
|
||||
"💖 Каждый донат помогает развивать и улучшать сервис. "
|
||||
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_donate_amount")
|
||||
async def process_enter_donate_amount(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
|
||||
await callback_query.message.answer(
|
||||
"💸 Введите сумму доната в рублях:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(DonateState.entering_donate_amount)
|
||||
|
||||
|
||||
@router.message(DonateState.entering_donate_amount)
|
||||
async def process_donate_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount // RUB_TO_XTR <= 0:
|
||||
await message.answer(
|
||||
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(DonateState.waiting_for_donate_confirmation)
|
||||
|
||||
try:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
|
||||
|
||||
await message.answer_invoice(
|
||||
title=f"Донат проекту {amount} рублей",
|
||||
description="Спасибо за вашу поддержку!",
|
||||
prices=[LabeledPrice(label="Донат", amount=int(amount // RUB_TO_XTR))],
|
||||
provider_token="",
|
||||
payload=f"{amount}_donate",
|
||||
currency="XTR",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(DonateState.waiting_for_donate_payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании доната: {e}")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
|
||||
|
||||
@router.pre_checkout_query(DonateState.waiting_for_donate_payment)
|
||||
async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
|
||||
async def on_successful_donate(message: types.Message, state: FSMContext):
|
||||
try:
|
||||
amount = float(message.successful_payment.invoice_payload.split("_")[0])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
await message.answer(
|
||||
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
except ValueError as e:
|
||||
logger.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Произошла ошибка при обработке доната: {e}")
|
||||
@@ -0,0 +1,11 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .instructions import router as instructions_router
|
||||
|
||||
router = Router(name="instructions_main_router")
|
||||
|
||||
router.include_routers(
|
||||
instructions_router,
|
||||
)
|
||||
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,34 +1,157 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.types import (BufferedInputFile, InlineKeyboardButton,
|
||||
InlineKeyboardMarkup)
|
||||
import asyncpg
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from handlers.texts import INSTRUCTIONS
|
||||
from config import CONNECT_MACOS, CONNECT_WINDOWS, DATABASE_URL, SUPPORT_CHAT_URL
|
||||
from handlers.texts import CONNECT_TV_TEXT, INSTRUCTION_PC, INSTRUCTIONS, KEY_MESSAGE, SUBSCRIPTION_DETAILS_TEXT
|
||||
|
||||
async def send_instructions(callback_query: types.CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
router = Router()
|
||||
|
||||
instructions_message = (
|
||||
INSTRUCTIONS
|
||||
)
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), 'instructions.jpg')
|
||||
@router.callback_query(F.data == "instructions")
|
||||
@router.message(F.text == "/instructions")
|
||||
async def send_instructions(callback_query_or_message: types.CallbackQuery | types.Message):
|
||||
instructions_message = INSTRUCTIONS
|
||||
image_path = os.path.join("img", "instructions.jpg")
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
await callback_query.answer()
|
||||
if isinstance(callback_query_or_message, types.CallbackQuery):
|
||||
await callback_query_or_message.message.answer("Файл изображения не найден.")
|
||||
else:
|
||||
await callback_query_or_message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
back_button = InlineKeyboardButton(text='🔙 Назад', callback_data='back_to_main')
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"),
|
||||
)
|
||||
|
||||
with open(image_path, 'rb') as image_from_buffer:
|
||||
await callback_query.message.answer_photo(
|
||||
if isinstance(callback_query_or_message, types.CallbackQuery):
|
||||
send_photo = callback_query_or_message.message.answer_photo
|
||||
else:
|
||||
send_photo = callback_query_or_message.answer_photo
|
||||
|
||||
with open(image_path, "rb") as image_from_buffer:
|
||||
await send_photo(
|
||||
BufferedInputFile(image_from_buffer.read(), filename="instructions.jpg"),
|
||||
caption=instructions_message,
|
||||
parse_mode='Markdown',
|
||||
reply_markup=keyboard
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_pc|"))
|
||||
async def process_connect_pc(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
key_name,
|
||||
)
|
||||
|
||||
if not record:
|
||||
await callback_query.message.answer(
|
||||
"❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍"
|
||||
)
|
||||
return
|
||||
|
||||
key = record["key"]
|
||||
key_message = KEY_MESSAGE.format(key)
|
||||
instruction_message = f"{key_message}{INSTRUCTION_PC}"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=f"{SUPPORT_CHAT_URL}"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
instruction_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("connect_tv|"))
|
||||
async def process_connect_tv(callback_query: types.CallbackQuery):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="▶ Продолжить", callback_data=f"continue_tv|{key_name}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=CONNECT_TV_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("continue_tv|"))
|
||||
async def process_continue_tv(callback_query: types.CallbackQuery):
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
print(f"tg_id: {tg_id}, key_name: {key_name}") # Отладочный вывод
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow(
|
||||
"""
|
||||
SELECT k.key
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
key_name,
|
||||
)
|
||||
|
||||
print(f"Query result: {record}") # Отладочный вывод результата
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
subscription_link = record['key']
|
||||
|
||||
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=message_text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from .key_management import router as management_router
|
||||
from .keys import router as keys_router
|
||||
|
||||
router = Router(name="keys_main_router")
|
||||
|
||||
router.include_routers(
|
||||
keys_router,
|
||||
management_router,
|
||||
)
|
||||
@@ -1,220 +1,196 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from bot import dp
|
||||
import asyncpg
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import (CallbackQuery, InlineKeyboardButton,
|
||||
InlineKeyboardMarkup, Message)
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from auth import login_with_credentials, link_subscription
|
||||
from client import add_client
|
||||
from config import (ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL,
|
||||
SERVERS)
|
||||
from database import add_connection, get_balance, store_key, update_balance
|
||||
from handlers.instructions.instructions import send_instructions
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import KEY, KEY_TRIAL, NULL_BALANCE, key_message_success
|
||||
from handlers.utils import sanitize_key_name
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PRICES,
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
)
|
||||
from database import get_balance, get_trial, store_key, update_balance
|
||||
from handlers.keys.key_utils import create_key_on_cluster
|
||||
from handlers.texts import DISCOUNTS, key_message_success
|
||||
from handlers.utils import generate_random_email, get_least_loaded_cluster
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class Form(StatesGroup):
|
||||
waiting_for_server_selection = State()
|
||||
waiting_for_key_name = State()
|
||||
viewing_profile = State()
|
||||
waiting_for_message = State()
|
||||
|
||||
@dp.callback_query(F.data == 'create_key')
|
||||
async def process_callback_create_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
server_buttons = []
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
for server_id, server in SERVERS.items():
|
||||
count = await conn.fetchval('SELECT COUNT(*) FROM keys WHERE server_id = $1', server_id)
|
||||
percent_full = (count / 60) * 100 if count <= 60 else 100
|
||||
server_name = f"{server['name']} ({percent_full:.1f}%)"
|
||||
server_buttons.append([InlineKeyboardButton(text=server_name, callback_data=f'select_server|{server_id}')])
|
||||
finally:
|
||||
await conn.close()
|
||||
@router.callback_query(F.data == "create_key")
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
|
||||
button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='view_profile')
|
||||
server_buttons.append([button_back])
|
||||
logger.info(f"User {tg_id} confirmed creation of a new key.")
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
"<b>⚙️ Выберите сервер для создания ключа:</b>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=server_buttons)
|
||||
)
|
||||
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
logger.info(f"Balance for user {tg_id} is sufficient. Proceeding with key creation.")
|
||||
|
||||
await callback_query.answer()
|
||||
await handle_key_creation(tg_id, state, session, callback_query)
|
||||
|
||||
|
||||
@dp.callback_query(F.data.startswith('select_server|'))
|
||||
async def select_server(callback_query: CallbackQuery, state: FSMContext):
|
||||
server_id = callback_query.data.split('|')[1]
|
||||
await state.update_data(selected_server_id=server_id)
|
||||
async def handle_key_creation(
|
||||
tg_id: int, state: FSMContext, session: Any, message_or_query: Message | CallbackQuery
|
||||
):
|
||||
"""Создание ключа с учётом выбора тарифного плана."""
|
||||
current_time = datetime.utcnow()
|
||||
trial_status = await get_trial(tg_id, session)
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_connection = await conn.fetchrow('SELECT trial FROM connections WHERE tg_id = $1', callback_query.from_user.id)
|
||||
finally:
|
||||
await conn.close()
|
||||
if trial_status == 0:
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME)
|
||||
logger.info(f"Assigned 1-day trial to user {tg_id}.")
|
||||
|
||||
trial_status = existing_connection['trial'] if existing_connection else 0
|
||||
|
||||
if trial_status == 1:
|
||||
await callback_query.message.edit_text(
|
||||
KEY,
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='✅ Да, создать новый ключ', callback_data='confirm_create_new_key')],
|
||||
[InlineKeyboardButton(text='↩️ Назад', callback_data='cancel_create_key')]
|
||||
])
|
||||
)
|
||||
await state.update_data(creating_new_key=True)
|
||||
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id)
|
||||
await create_key(tg_id, expiry_time, state, session, message_or_query)
|
||||
else:
|
||||
await callback_query.message.edit_text(
|
||||
KEY_TRIAL,
|
||||
parse_mode="HTML"
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
|
||||
discount_text = ""
|
||||
|
||||
if plan_id in DISCOUNTS:
|
||||
discount_percentage = DISCOUNTS[plan_id]
|
||||
discount_text = f" ({discount_percentage}% скидка)"
|
||||
|
||||
if index == len(RENEWAL_PRICES) - 1:
|
||||
discount_text = f" ({discount_percentage}% 🔥)"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {plan_id} мес. - {price}₽{discount_text}", callback_data=f"select_plan_{plan_id}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
await message_or_query.message.answer(
|
||||
"💳 Выберите тарифный план для создания нового ключа:", reply_markup=builder.as_markup()
|
||||
)
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
await state.update_data(tg_id=tg_id)
|
||||
await state.set_state(Form.waiting_for_server_selection)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@dp.callback_query(F.data == 'confirm_create_new_key')
|
||||
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
data = await state.get_data()
|
||||
server_id = data.get('selected_server_id')
|
||||
@router.callback_query(F.data.startswith("select_plan_"))
|
||||
async def select_tariff_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan_id = callback_query.data.split("_")[-1]
|
||||
plan_price = RENEWAL_PRICES.get(plan_id)
|
||||
|
||||
if plan_price is None:
|
||||
await callback_query.message.answer("🚫 Неверный тарифный план.")
|
||||
return
|
||||
|
||||
duration_days = int(plan_id) * 30
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < 100:
|
||||
replenish_button = InlineKeyboardButton(text='Перейти в профиль', callback_data='view_profile')
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[replenish_button]])
|
||||
await callback_query.message.edit_text(
|
||||
NULL_BALANCE,
|
||||
reply_markup=keyboard
|
||||
if balance < plan_price:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
await callback_query.message.answer(
|
||||
"💳 Недостаточно средств для создания подписки. Пополните баланс в личном кабинете.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await callback_query.message.edit_text("🔑 Пожалуйста, введите имя нового ключа:")
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
await state.update_data(creating_new_key=True)
|
||||
await update_balance(tg_id, -plan_price)
|
||||
|
||||
await callback_query.answer()
|
||||
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
@dp.callback_query(F.data == 'cancel_create_key')
|
||||
async def cancel_create_key(callback_query: CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
await callback_query.answer()
|
||||
await create_key(tg_id, expiry_time, state, session, callback_query)
|
||||
|
||||
async def handle_key_name_input(message: Message, state: FSMContext):
|
||||
tg_id = message.from_user.id
|
||||
key_name = sanitize_key_name(message.text)
|
||||
|
||||
if not key_name:
|
||||
await message.bot.send_message(tg_id, "📝 Пожалуйста, назовите ключ устройства на английском языке.")
|
||||
return
|
||||
async def create_key(
|
||||
tg_id: int, expiry_time: datetime, state: FSMContext, session: Any, message_or_query: Message | CallbackQuery
|
||||
):
|
||||
"""Создаёт ключ с заданным сроком действия."""
|
||||
while True:
|
||||
key_name = generate_random_email()
|
||||
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
|
||||
|
||||
data = await state.get_data()
|
||||
creating_new_key = data.get('creating_new_key', False)
|
||||
server_id = data.get('selected_server_id')
|
||||
existing_key = await session.fetchrow(
|
||||
"SELECT * FROM keys WHERE email = $1 AND tg_id = $2",
|
||||
key_name,
|
||||
tg_id,
|
||||
)
|
||||
if not existing_key:
|
||||
break
|
||||
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
client_id = str(uuid.uuid4())
|
||||
email = key_name.lower()
|
||||
current_time = datetime.utcnow()
|
||||
expiry_time = None
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_connection = await conn.fetchrow('SELECT trial FROM connections WHERE tg_id = $1', tg_id)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
trial_status = existing_connection['trial'] if existing_connection else 0
|
||||
|
||||
if trial_status == 0:
|
||||
expiry_time = current_time + timedelta(days=1, hours=3)
|
||||
else:
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < 100:
|
||||
replenish_button = InlineKeyboardButton(text='Перейти в профиль', callback_data='view_profile')
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[replenish_button]])
|
||||
await message.bot.send_message(tg_id, "❗️ Недостаточно средств на балансе для создания нового ключа.", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_balance(tg_id, -100)
|
||||
expiry_time = current_time + timedelta(days=30, hours=3)
|
||||
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
try:
|
||||
response = await add_client(session, server_id, client_id, email, tg_id, limit_ip=1, total_gb=0, expiry_time=expiry_timestamp, enable=True, flow="xtls-rprx-vision")
|
||||
|
||||
if not response.get("success", True):
|
||||
error_msg = response.get("msg", "Неизвестная ошибка.")
|
||||
if "Duplicate email" in error_msg:
|
||||
await message.bot.send_message(tg_id, "❌ Это имя уже используется. Пожалуйста, выберите другое имя для ключа.")
|
||||
await state.set_state(Form.waiting_for_key_name)
|
||||
return
|
||||
else:
|
||||
raise Exception(error_msg)
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
|
||||
connection_link = await link_subscription(email, server_id)
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
create_key_on_cluster(
|
||||
least_loaded_cluster,
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
existing_connection = await conn.fetchrow('SELECT * FROM connections WHERE tg_id = $1', tg_id)
|
||||
await asyncio.gather(*tasks)
|
||||
logger.info(f"Key created on cluster {least_loaded_cluster} for user {tg_id}.")
|
||||
|
||||
if existing_connection:
|
||||
await conn.execute('UPDATE connections SET trial = 1 WHERE tg_id = $1', tg_id)
|
||||
else:
|
||||
await add_connection(tg_id, 0, 1)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await store_key(tg_id, client_id, email, expiry_timestamp, connection_link, server_id)
|
||||
|
||||
remaining_time = expiry_time - current_time
|
||||
days = remaining_time.days
|
||||
hours, remainder = divmod(remaining_time.seconds, 3600)
|
||||
minutes, _ = divmod(remainder, 60)
|
||||
|
||||
remaining_time_message = (
|
||||
f"Оставшееся время ключа: {days} день"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='📘 Инструкции по использованию', callback_data='instructions')],
|
||||
[InlineKeyboardButton(text='🔙 Перейти в профиль', callback_data='view_profile')]
|
||||
])
|
||||
|
||||
key_message = (
|
||||
key_message_success(connection_link, remaining_time_message)
|
||||
)
|
||||
|
||||
await message.bot.send_message(tg_id, key_message, parse_mode="HTML", reply_markup=keyboard)
|
||||
await store_key(tg_id, client_id, email, expiry_timestamp, public_link, least_loaded_cluster, session)
|
||||
|
||||
except Exception as e:
|
||||
await message.bot.send_message(tg_id, f"❌ Ошибка при создании ключа: {e}")
|
||||
logger.error(f"Error while creating the key for user {tg_id} on cluster: {e}")
|
||||
|
||||
if isinstance(message_or_query, Message):
|
||||
await message_or_query.answer("❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова.")
|
||||
elif isinstance(message_or_query, CallbackQuery):
|
||||
await message_or_query.message.answer(
|
||||
"❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова."
|
||||
)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🍏 Подключить на iOS", url=f"{CONNECT_IOS}{public_link}"),
|
||||
InlineKeyboardButton(text="🤖 Подключить на Android", url=f"{CONNECT_ANDROID}{public_link}"),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="💻 Windows/Linux/MacOS", callback_data=f"connect_pc|{email}"))
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
remaining_time = expiry_time - datetime.utcnow()
|
||||
days = remaining_time.days
|
||||
key_message = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
|
||||
|
||||
if isinstance(message_or_query, Message):
|
||||
await message_or_query.answer(key_message, reply_markup=builder.as_markup())
|
||||
elif isinstance(message_or_query, CallbackQuery):
|
||||
await message_or_query.message.answer(key_message, reply_markup=builder.as_markup())
|
||||
|
||||
await state.clear()
|
||||
|
||||
@dp.callback_query(F.data == 'instructions')
|
||||
async def handle_instructions(callback_query: CallbackQuery):
|
||||
await send_instructions(callback_query)
|
||||
|
||||
@dp.callback_query(F.data == 'back_to_main')
|
||||
async def handle_back_to_main(callback_query: CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
await callback_query.answer()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import asyncio
|
||||
|
||||
import asyncpg
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import add_client, delete_client, extend_client_key
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, LIMIT_IP, TOTAL_GB
|
||||
from database import get_servers_from_db
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def create_key_on_cluster(cluster_id, tg_id, client_id, email, expiry_timestamp):
|
||||
try:
|
||||
tasks = []
|
||||
servers = await get_servers_from_db()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
existing_key = await conn.fetchrow(
|
||||
"SELECT 1 FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if existing_key:
|
||||
raise ValueError(f"Email {email} уже существует в базе данных.")
|
||||
|
||||
tasks.append(
|
||||
add_client(
|
||||
xui,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
)
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании ключа: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
async def renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb):
|
||||
try:
|
||||
servers = await get_servers_from_db()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
extend_client_key(
|
||||
xui,
|
||||
int(inbound_id),
|
||||
email,
|
||||
new_expiry_time,
|
||||
client_id,
|
||||
total_gb,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось продлить ключ {client_id} в кластере {cluster_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
async def delete_key_from_db(client_id, session):
|
||||
try:
|
||||
await session.execute("DELETE FROM keys WHERE client_id = $1", client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id} из базы данных: {e}")
|
||||
|
||||
|
||||
async def delete_key_from_cluster(cluster_id, email, client_id):
|
||||
"""Удаление ключа с серверов в кластере"""
|
||||
try:
|
||||
servers = await get_servers_from_db()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
delete_client(
|
||||
xui,
|
||||
int(inbound_id),
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось удалить ключ {client_id} в кластере {cluster_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
async def update_key_on_cluster(tg_id, client_id, email, expiry_time, cluster_id):
|
||||
try:
|
||||
servers = await get_servers_from_db()
|
||||
cluster = servers.get(cluster_id)
|
||||
|
||||
if not cluster:
|
||||
raise ValueError(f"Кластер с ID {cluster_id} не найден.")
|
||||
|
||||
tasks = []
|
||||
for server_info in cluster:
|
||||
xui = AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
)
|
||||
|
||||
inbound_id = server_info.get("inbound_id")
|
||||
if not inbound_id:
|
||||
logger.warning(
|
||||
f"INBOUND_ID отсутствует для сервера {server_info.get('server_name', 'unknown')}. Пропуск."
|
||||
)
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
add_client(
|
||||
xui,
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=LIMIT_IP,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_time,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(inbound_id),
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
logger.info(
|
||||
f"Ключ успешно обновлен для {client_id} на всех серверах в кластере {cluster_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при обновлении ключа на серверах кластера {cluster_id} для {client_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
@@ -1,385 +1,583 @@
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from aiogram import Router, types
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from auth import login_with_credentials, link_subscription
|
||||
from bot import bot
|
||||
from client import add_client, delete_client, extend_client_key
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, SERVERS, APP_URL
|
||||
from database import get_balance, update_balance
|
||||
from handlers.texts import NO_KEYS
|
||||
from handlers.texts import key_message, key_relocated
|
||||
from handlers.texts import RENEWAL_PLANS, INSUFFICIENT_FUNDS_MSG, KEY_NOT_FOUND_MSG, SUCCESS_RENEWAL_MSG, ERROR_RENEWAL_MSG, PLAN_SELECTION_MSG
|
||||
from config import (
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
PUBLIC_LINK,
|
||||
RENEWAL_PLANS,
|
||||
TOTAL_GB,
|
||||
)
|
||||
from database import (
|
||||
delete_key,
|
||||
get_balance,
|
||||
get_servers_from_db,
|
||||
store_key,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.keys.key_utils import (
|
||||
delete_key_from_cluster,
|
||||
delete_key_from_db,
|
||||
renew_key_in_cluster,
|
||||
update_key_on_cluster,
|
||||
)
|
||||
from handlers.texts import (
|
||||
DISCOUNTS,
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
KEY_NOT_FOUND_MSG,
|
||||
PLAN_SELECTION_MSG,
|
||||
SUCCESS_RENEWAL_MSG,
|
||||
key_message,
|
||||
)
|
||||
from handlers.utils import get_least_loaded_cluster, handle_error
|
||||
from logger import logger
|
||||
|
||||
locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8')
|
||||
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
|
||||
|
||||
router = Router()
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'view_keys')
|
||||
async def process_callback_view_keys(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
@router.callback_query(F.data == "view_keys")
|
||||
@router.message(F.text == "/subs")
|
||||
async def process_callback_or_message_view_keys(
|
||||
callback_query_or_message: types.Message | types.CallbackQuery, session: Any
|
||||
):
|
||||
if isinstance(callback_query_or_message, types.CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
send_message = callback_query_or_message.message.answer
|
||||
send_photo = callback_query_or_message.message.answer_photo
|
||||
else:
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
send_message = callback_query_or_message.answer
|
||||
send_photo = callback_query_or_message.answer_photo
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
records = await conn.fetch('''
|
||||
SELECT email, client_id FROM keys WHERE tg_id = $1
|
||||
''', tg_id)
|
||||
|
||||
if records:
|
||||
buttons = []
|
||||
for record in records:
|
||||
key_name = record['email']
|
||||
client_id = record['client_id']
|
||||
button = types.InlineKeyboardButton(text=f"🔑 {key_name}", callback_data=f'view_key|{key_name}|{client_id}')
|
||||
buttons.append([button])
|
||||
|
||||
back_button = types.InlineKeyboardButton(text='🔙 Назад', callback_data='view_profile')
|
||||
buttons.append([back_button])
|
||||
|
||||
inline_keyboard = types.InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
response_message = (
|
||||
"<b>Это ваши устройства:</b>\n\n"
|
||||
"<i>Нажмите на имя устройства для управления его ключом.</i>" # Добавлено курсивом
|
||||
)
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=inline_keyboard, parse_mode="HTML")
|
||||
else:
|
||||
response_message = (
|
||||
NO_KEYS
|
||||
)
|
||||
create_key_button = types.InlineKeyboardButton(text='➕ Создать ключ', callback_data='create_key')
|
||||
back_button = types.InlineKeyboardButton(text='🔙 Назад', callback_data='view_profile')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[create_key_button], [back_button]])
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при получении ключей: {e}")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('view_key|'))
|
||||
async def process_callback_view_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
key_name, client_id = callback_query.data.split('|')[1], callback_query.data.split('|')[2]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('''
|
||||
SELECT k.key, k.expiry_time, k.server_id
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
''', tg_id, key_name)
|
||||
|
||||
if record:
|
||||
key = record['key']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
|
||||
server_name = SERVERS.get(server_id, {}).get('name', 'Неизвестный сервер')
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "<b>Ключ истек.</b>"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime('%d %B %Y года')
|
||||
|
||||
response_message = (
|
||||
key_message(key, formatted_expiry_date, days_left_message, server_name)
|
||||
)
|
||||
|
||||
renew_button = types.InlineKeyboardButton(text='⏳ Продлить ключ', callback_data=f'renew_key|{client_id}')
|
||||
instructions_button = types.InlineKeyboardButton(text='📘 Инструкции', callback_data='instructions')
|
||||
delete_button = types.InlineKeyboardButton(text='❌ Удалить ключ', callback_data=f'delete_key|{client_id}')
|
||||
change_location_button = types.InlineKeyboardButton(text='🌍 Сменить локацию', callback_data=f'change_location|{client_id}')
|
||||
back_button = types.InlineKeyboardButton(text='🔙 Назад в профиль', callback_data='view_profile')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[instructions_button],
|
||||
[renew_button, delete_button],
|
||||
[change_location_button],
|
||||
[back_button]
|
||||
]
|
||||
)
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await bot.edit_message_text("<b>Информация о ключе не найдена.</b>", chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await handle_error(tg_id, callback_query, f"Ошибка при получении информации о ключе: {e}")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('delete_key|'))
|
||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split('|')[1]
|
||||
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Да, удалить', callback_data=f'confirm_delete|{client_id}')],
|
||||
[types.InlineKeyboardButton(text='❌ Нет, отменить', callback_data='view_keys')]
|
||||
])
|
||||
|
||||
await bot.edit_message_text("<b>Вы уверены, что хотите удалить ключ?</b>", chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=confirmation_keyboard, parse_mode="HTML")
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('renew_key|'))
|
||||
async def process_callback_renew_key(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split('|')[1]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT email, expiry_time FROM keys WHERE client_id = $1', client_id)
|
||||
|
||||
if record:
|
||||
email = record['email']
|
||||
expiry_time = record['expiry_time']
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=f'📅 1 месяц ({RENEWAL_PLANS["1"]["price"]} руб.)', callback_data=f'renew_plan|1|{client_id}')],
|
||||
[types.InlineKeyboardButton(text=f'📅 3 месяца ({RENEWAL_PLANS["3"]["price"]} руб.)', callback_data=f'renew_plan|3|{client_id}')],
|
||||
[types.InlineKeyboardButton(text=f'📅 6 месяцев ({RENEWAL_PLANS["6"]["price"]} руб.)', callback_data=f'renew_plan|6|{client_id}')],
|
||||
[types.InlineKeyboardButton(text=f'📅 12 месяцев ({RENEWAL_PLANS["12"]["price"]} руб.)', callback_data=f'renew_plan|12|{client_id}')],
|
||||
[types.InlineKeyboardButton(text='🔙 Назад', callback_data='view_profile')]
|
||||
])
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
response_message = PLAN_SELECTION_MSG.format(balance=balance, expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(f"<b>Ошибка при выборе плана:</b> {e}", chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode="HTML")
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('confirm_delete|'))
|
||||
async def process_callback_confirm_delete(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split('|')[1]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT email, server_id FROM keys WHERE client_id = $1', client_id)
|
||||
|
||||
if record:
|
||||
email = record['email']
|
||||
server_id = record['server_id']
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success = await delete_client(session, server_id, client_id)
|
||||
|
||||
if success:
|
||||
await conn.execute('DELETE FROM keys WHERE client_id = $1', client_id)
|
||||
response_message = "Ключ был успешно удален."
|
||||
else:
|
||||
response_message = "Ошибка при удалении клиента через API."
|
||||
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
|
||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_keys')
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(f"Ошибка при удалении ключа: {e}", chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('renew_plan|'))
|
||||
async def process_callback_renew_plan(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
plan, client_id = callback_query.data.split('|')[1], callback_query.data.split('|')[2]
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
record = await conn.fetchrow('SELECT email, expiry_time, server_id FROM keys WHERE client_id = $1', client_id)
|
||||
|
||||
if record:
|
||||
email = record['email']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
else:
|
||||
new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
|
||||
|
||||
cost = RENEWAL_PLANS[plan]['price']
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < cost:
|
||||
replenish_button = types.InlineKeyboardButton(text='Пополнить баланс', callback_data='replenish_balance')
|
||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_profile')
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[replenish_button], [back_button]])
|
||||
|
||||
await bot.edit_message_text(INSUFFICIENT_FUNDS_MSG, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
||||
return
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success = await extend_client_key(session, server_id, tg_id, client_id, email, new_expiry_time)
|
||||
|
||||
if success:
|
||||
await update_balance(tg_id, -cost)
|
||||
await conn.execute('UPDATE keys SET expiry_time = $1 WHERE client_id = $2', new_expiry_time, client_id)
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(months=RENEWAL_PLANS[plan]['months'])
|
||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_profile')
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard)
|
||||
else:
|
||||
await bot.edit_message_text(ERROR_RENEWAL_MSG, chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
else:
|
||||
await bot.edit_message_text(KEY_NOT_FOUND_MSG, chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(f"Ошибка при продлении ключа: {e}", chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
async def handle_error(tg_id, callback_query, message):
|
||||
await bot.edit_message_text(message, chat_id=tg_id, message_id=callback_query.message.message_id)
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('change_location|'))
|
||||
async def process_callback_change_location(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
client_id = callback_query.data.split('|')[1]
|
||||
server_buttons = []
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
for server_id, server in SERVERS.items():
|
||||
count = await conn.fetchval('SELECT COUNT(*) FROM keys WHERE server_id = $1', server_id)
|
||||
percent_full = (count / 60) * 100 if count <= 60 else 100
|
||||
server_name = f"{server['name']} ({percent_full:.1f}%)"
|
||||
server_buttons.append([types.InlineKeyboardButton(text=server_name, callback_data=f'select_server&{server_id}&{client_id}')])
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=server_buttons)
|
||||
|
||||
response_message = "<b>Выберите новый сервер для вашего ключа:</b>"
|
||||
await bot.edit_message_text(response_message, chat_id=tg_id, message_id=callback_query.message.message_id, reply_markup=keyboard, parse_mode="HTML")
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('select_server&'))
|
||||
async def process_callback_select_server(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
server_id, client_id = callback_query.data.split('&')[1], callback_query.data.split('&')[2]
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
record = await conn.fetchrow(
|
||||
'SELECT email, expiry_time, server_id FROM keys WHERE client_id = $1 FOR UPDATE', client_id
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record['email']
|
||||
expiry_time = record['expiry_time']
|
||||
current_server_id = record['server_id']
|
||||
|
||||
if current_server_id == server_id:
|
||||
await callback_query.answer("Клиент уже на этом сервере.")
|
||||
return
|
||||
|
||||
session_new = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
new_client_data = await add_client(
|
||||
session_new, server_id, client_id, email, tg_id, limit_ip=1, total_gb=0,
|
||||
expiry_time=int(datetime.utcnow().timestamp() * 1000) + (expiry_time - datetime.utcnow().timestamp() * 1000),
|
||||
enable=True, flow="xtls-rprx-vision"
|
||||
)
|
||||
|
||||
if not new_client_data:
|
||||
raise Exception("Ошибка при создании клиента на новом сервере.")
|
||||
|
||||
new_key = await link_subscription(email, server_id)
|
||||
|
||||
await conn.execute(
|
||||
'UPDATE keys SET server_id = $1, key = $2 WHERE client_id = $3',
|
||||
server_id, new_key, client_id
|
||||
)
|
||||
|
||||
try:
|
||||
session_old = await login_with_credentials(current_server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success_delete = await delete_client(session_old, current_server_id, client_id)
|
||||
|
||||
if not success_delete:
|
||||
raise Exception(f"Ошибка при удалении клиента с сервера {current_server_id}")
|
||||
|
||||
response_message = key_relocated(new_key)
|
||||
except Exception as e:
|
||||
response_message = f"Ключ перемещен, но возникла ошибка при удалении клиента с текущего сервера: {e}"
|
||||
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
|
||||
back_button = types.InlineKeyboardButton(text='Назад', callback_data='view_keys')
|
||||
iphone_button = types.InlineKeyboardButton(
|
||||
text='🍏IPhone',
|
||||
url=f'{APP_URL}/?url=streisand://import/{new_key}'
|
||||
)
|
||||
android_button = types.InlineKeyboardButton(
|
||||
text='🤖Android',
|
||||
url=f'{APP_URL}/?url=v2rayng://install-sub?url={new_key}'
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[back_button],
|
||||
[iphone_button, android_button]
|
||||
]
|
||||
)
|
||||
|
||||
await bot.edit_message_text(
|
||||
response_message, chat_id=tg_id, message_id=callback_query.message.message_id,
|
||||
reply_markup=keyboard, parse_mode='HTML'
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
await bot.edit_message_text(
|
||||
f"Ошибка при смене локации: {e}", chat_id=tg_id, message_id=callback_query.message.message_id, parse_mode='HTML'
|
||||
records = await session.fetch(
|
||||
"""
|
||||
SELECT email, client_id FROM keys WHERE tg_id = $1
|
||||
""",
|
||||
chat_id,
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
if records:
|
||||
inline_keyboard, response_message = build_keys_response(records)
|
||||
else:
|
||||
inline_keyboard, response_message = build_no_keys_response()
|
||||
|
||||
image_path = os.path.join("img", "pic_keys.jpg")
|
||||
await send_with_optional_image(
|
||||
send_message, send_photo, image_path, response_message, inline_keyboard
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = f"Ошибка при получении ключей: {e}"
|
||||
await send_message(text=error_message)
|
||||
|
||||
|
||||
def build_keys_response(records):
|
||||
"""
|
||||
Формирует сообщение и клавиатуру, если у пользователя есть устройства.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
for record in records:
|
||||
key_name = record["email"]
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"🔑 {key_name}", callback_data=f"view_key|{key_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
response_message = (
|
||||
"<b>🔑 Список ваших устройств</b>\n\n"
|
||||
"<i>👇 Выберите устройство для управления подпиской:</i>"
|
||||
)
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
def build_no_keys_response():
|
||||
"""
|
||||
Формирует сообщение и клавиатуру, если у пользователя нет устройств.
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Создать подписку", callback_data="create_key"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
inline_keyboard = builder.as_markup()
|
||||
response_message = (
|
||||
"<b>❌ У вас пока нет активных устройств</b>\n\n"
|
||||
"<i>Нажмите кнопку ниже, чтобы создать устройство:</i>"
|
||||
)
|
||||
return inline_keyboard, response_message
|
||||
|
||||
|
||||
async def send_with_optional_image(send_message, send_photo, image_path, text, keyboard):
|
||||
"""
|
||||
Отправляет сообщение с изображением, если файл существует. В противном случае отправляет только текст.
|
||||
"""
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await send_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename=os.path.basename(image_path)),
|
||||
caption=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await send_message(
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("view_key|"))
|
||||
async def process_callback_view_key(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.expiry_time, k.server_id, k.key
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
key_name,
|
||||
)
|
||||
|
||||
if record:
|
||||
key = record["key"]
|
||||
expiry_time = record["expiry_time"]
|
||||
server_name = record["server_id"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = (
|
||||
"<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0"
|
||||
)
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
|
||||
|
||||
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
|
||||
response_message = key_message(
|
||||
key, formatted_expiry_date, days_left_message, server_name
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🍏 Подключить на iOS", url=f"{CONNECT_IOS}{key}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Подключить на Android", url=f"{CONNECT_ANDROID}{key}"
|
||||
),
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💻 Компьютеры", callback_data=f"connect_pc|{key_name}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📺 Андроид TV", callback_data=f"connect_tv|{key_name}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⏳ Продлить", callback_data=f"renew_key|{key_name}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="❌ Удалить", callback_data=f"delete_key|{key_name}"
|
||||
),
|
||||
)
|
||||
|
||||
if not key.startswith(PUBLIC_LINK):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить подписку",
|
||||
callback_data=f"update_subscription|{key_name}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
image_path = os.path.join("img", "pic_view.jpg")
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await callback_query.message.answer("Файл изображения не найден.")
|
||||
return
|
||||
|
||||
with open(image_path, "rb") as image_file:
|
||||
await callback_query.message.answer_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename="pic_view.jpg"),
|
||||
caption=response_message,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
text="<b>Информация о подписке не найдена.</b>",
|
||||
)
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id,
|
||||
callback_query,
|
||||
f"Ошибка при получении информации о ключе: {e}",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("update_subscription|"))
|
||||
async def process_callback_update_subscription(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
|
||||
FROM keys k
|
||||
WHERE k.tg_id = $1 AND k.email = $2
|
||||
""",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
|
||||
if record:
|
||||
expiry_time = record["expiry_time"]
|
||||
client_id = record["client_id"]
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
|
||||
try:
|
||||
await session.execute(
|
||||
"""
|
||||
DELETE FROM keys
|
||||
WHERE tg_id = $1 AND email = $2
|
||||
""",
|
||||
tg_id,
|
||||
email,
|
||||
)
|
||||
except Exception as delete_error:
|
||||
await callback_query.message.answer(
|
||||
f"Ошибка при удалении старой подписки: {delete_error}",
|
||||
)
|
||||
return
|
||||
|
||||
least_loaded_cluster_id = await get_least_loaded_cluster()
|
||||
|
||||
await asyncio.gather(
|
||||
update_key_on_cluster(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_time,
|
||||
least_loaded_cluster_id,
|
||||
)
|
||||
)
|
||||
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_time,
|
||||
public_link,
|
||||
server_id=least_loaded_cluster_id,
|
||||
session=session,
|
||||
)
|
||||
|
||||
await process_callback_view_key(callback_query, session)
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден в базе данных.</b>")
|
||||
except Exception as e:
|
||||
await handle_error(
|
||||
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("delete_key|"))
|
||||
async def process_callback_delete_key(callback_query: types.CallbackQuery):
|
||||
client_id = callback_query.data.split("|")[1]
|
||||
try:
|
||||
confirmation_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Да, удалить",
|
||||
callback_data=f"confirm_delete|{client_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Нет, отменить", callback_data="view_keys"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text="<b>Вы уверены, что хотите удалить ключ?</b>",
|
||||
reply_markup=confirmation_keyboard,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_key|"))
|
||||
async def process_callback_renew_key(callback_query: types.CallbackQuery, session: Any):
|
||||
tg_id = callback_query.message.chat.id
|
||||
key_name = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"""
|
||||
SELECT client_id, expiry_time
|
||||
FROM keys
|
||||
WHERE email = $1
|
||||
""",
|
||||
key_name,
|
||||
)
|
||||
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 1 месяц ({RENEWAL_PLANS["1"]["price"]} руб.)',
|
||||
callback_data=f"renew_plan|1|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 3 месяца ({RENEWAL_PLANS["3"]["price"]} руб.) {DISCOUNTS["3"]}% скидка',
|
||||
callback_data=f"renew_plan|3|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 6 месяцев ({RENEWAL_PLANS["6"]["price"]} руб.) {DISCOUNTS["6"]}% скидка',
|
||||
callback_data=f"renew_plan|6|{client_id}",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f'📅 12 месяцев ({RENEWAL_PLANS["12"]["price"]} руб.) ({DISCOUNTS["12"]}% 🔥)',
|
||||
callback_data=f"renew_plan|12|{client_id}",
|
||||
)
|
||||
)
|
||||
back_button = InlineKeyboardButton(
|
||||
text="🔙 Назад", callback_data="view_keys"
|
||||
)
|
||||
builder.row(back_button)
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
|
||||
response_message = PLAN_SELECTION_MSG.format(
|
||||
balance=balance,
|
||||
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=response_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer("<b>Ключ не найден.</b>")
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_delete|"))
|
||||
async def process_callback_confirm_delete(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
email = callback_query.data.split("|")[1]
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"SELECT client_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if record:
|
||||
client_id = record["client_id"]
|
||||
response_message = "Ключ успешно удален."
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="Назад", callback_data="view_keys"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await delete_key(client_id)
|
||||
await callback_query.message.answer(
|
||||
response_message,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
async def delete_key_from_servers():
|
||||
try:
|
||||
tasks = []
|
||||
for cluster_id, cluster in servers.items():
|
||||
tasks.append(
|
||||
delete_key_from_cluster(cluster_id, email, client_id)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
|
||||
|
||||
asyncio.create_task(delete_key_from_servers())
|
||||
|
||||
await delete_key_from_db(client_id, session)
|
||||
|
||||
else:
|
||||
response_message = "Ключ не найден или уже удален."
|
||||
back_button = types.InlineKeyboardButton(
|
||||
text="Назад", callback_data="view_keys"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("renew_plan|"))
|
||||
async def process_callback_renew_plan(
|
||||
callback_query: types.CallbackQuery, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
plan, client_id = (
|
||||
callback_query.data.split("|")[1],
|
||||
callback_query.data.split("|")[2],
|
||||
)
|
||||
days_to_extend = 30 * int(plan)
|
||||
|
||||
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
|
||||
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
|
||||
|
||||
try:
|
||||
record = await session.fetchrow(
|
||||
"SELECT email, expiry_time FROM keys WHERE client_id = $1",
|
||||
client_id,
|
||||
)
|
||||
|
||||
if record:
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
|
||||
if expiry_time <= current_time:
|
||||
new_expiry_time = int(
|
||||
current_time + timedelta(days=days_to_extend).total_seconds() * 1000
|
||||
)
|
||||
else:
|
||||
new_expiry_time = int(
|
||||
expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000
|
||||
)
|
||||
|
||||
cost = RENEWAL_PLANS[plan]["price"]
|
||||
|
||||
balance = await get_balance(tg_id)
|
||||
if balance < cost:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="Пополнить баланс", callback_data="pay")
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="profile"
|
||||
)
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
INSUFFICIENT_FUNDS_MSG,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
response_message = SUCCESS_RENEWAL_MSG.format(
|
||||
months=RENEWAL_PLANS[plan]["months"]
|
||||
)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
response_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
async def renew_key_on_servers():
|
||||
tasks = []
|
||||
for cluster_id in servers:
|
||||
task = asyncio.create_task(
|
||||
renew_key_in_cluster(
|
||||
cluster_id,
|
||||
email,
|
||||
client_id,
|
||||
new_expiry_time,
|
||||
total_gb,
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
await update_balance(tg_id, -cost)
|
||||
await update_key_expiry(client_id, new_expiry_time)
|
||||
|
||||
await renew_key_on_servers()
|
||||
|
||||
else:
|
||||
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import base64
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
from aiohttp import web
|
||||
|
||||
from config import DATABASE_URL, PROJECT_NAME, SUB_MESSAGE, TRANSITION_DATE_STR
|
||||
from database import get_servers_from_db
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def fetch_url_content(url, tg_id):
|
||||
try:
|
||||
logger.info(f"Получение URL: {url} для tg_id: {tg_id}")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, ssl=False) as response:
|
||||
if response.status == 200:
|
||||
content = await response.text()
|
||||
logger.info(f"Успешно получен контент с {url} для tg_id: {tg_id}")
|
||||
return base64.b64decode(content).decode("utf-8").split("\n")
|
||||
else:
|
||||
logger.error(
|
||||
f"Не удалось получить {url} для tg_id: {tg_id}, статус: {response.status}"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении {url} для tg_id: {tg_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def combine_unique_lines(urls, tg_id, query_string):
|
||||
all_lines = []
|
||||
logger.info(
|
||||
f"Начинаем объединение подписок для tg_id: {tg_id}, запрос: {query_string}"
|
||||
)
|
||||
|
||||
urls_with_query = [f"{url}?{query_string}" for url in urls]
|
||||
logger.info(f"Составлены URL-адреса: {urls_with_query}")
|
||||
|
||||
for url in urls_with_query:
|
||||
lines = await fetch_url_content(url, tg_id)
|
||||
all_lines.extend(lines)
|
||||
|
||||
all_lines = list(set(filter(None, all_lines)))
|
||||
logger.info(
|
||||
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для tg_id: {tg_id}"
|
||||
)
|
||||
|
||||
return all_lines
|
||||
|
||||
|
||||
transition_date = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
|
||||
|
||||
transition_timestamp_ms_adjusted = transition_timestamp_ms - (3 * 60 * 60 * 1000)
|
||||
|
||||
logger.info(
|
||||
f"Время перехода (с поправкой на часовой пояс): {transition_timestamp_ms_adjusted}"
|
||||
)
|
||||
|
||||
|
||||
async def handle_old_subscription(request):
|
||||
email = request.match_info.get("email")
|
||||
|
||||
if not email:
|
||||
logger.warning("Получен запрос без email")
|
||||
return web.Response(
|
||||
text="❌ Неверные параметры запроса. Требуется email.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
logger.info(f"Обработка запроса для старого клиента с email: {email}")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
key_info = await conn.fetchrow(
|
||||
"SELECT created_at, server_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if not key_info:
|
||||
logger.warning(f"Клиент с email {email} не найден в базе.")
|
||||
return web.Response(
|
||||
text="❌ Клиент с таким email не найден.",
|
||||
status=404,
|
||||
)
|
||||
|
||||
created_at_ms = key_info["created_at"]
|
||||
cluster_name = key_info.get("server_id")
|
||||
if not cluster_name:
|
||||
logger.warning(f"У клиента с email {email} отсутствует cluster_name.")
|
||||
return web.Response(
|
||||
text="❌ Устаревшие данные. Обратитесь в поддержку.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
logger.info(f"Значение created_at для клиента с email {email}: {created_at_ms}, кластер: {cluster_name}")
|
||||
|
||||
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
|
||||
logger.info(
|
||||
f"Время создания клиента в формате datetime (UTC): {created_at_datetime}"
|
||||
)
|
||||
|
||||
if created_at_ms >= transition_timestamp_ms_adjusted:
|
||||
logger.info(f"Клиент с email {email} является новым.")
|
||||
return web.Response(
|
||||
text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
logger.info(f"Сервера в кластере: {cluster_servers}")
|
||||
|
||||
urls = []
|
||||
for server in cluster_servers:
|
||||
server_subscription_url = f"{server['subscription_url']}/{email}"
|
||||
urls.append(server_subscription_url)
|
||||
|
||||
combined_subscriptions = await combine_unique_lines(urls, email, "")
|
||||
|
||||
base64_encoded = base64.b64encode(
|
||||
"\n".join(combined_subscriptions).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
|
||||
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
|
||||
headers = {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "7",
|
||||
"profile-title": "base64:" + base64.b64encode(
|
||||
encoded_project_name.encode("utf-8")
|
||||
).decode("utf-8"),
|
||||
}
|
||||
|
||||
logger.info(f"Возвращаем объединенные подписки для email: {email}")
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
|
||||
async def handle_new_subscription(request):
|
||||
email = request.match_info.get("email")
|
||||
tg_id = request.match_info.get("tg_id")
|
||||
|
||||
if not email or not tg_id:
|
||||
logger.warning("Получен запрос с отсутствующими параметрами email или tg_id")
|
||||
return web.Response(
|
||||
text="❌ Неверные параметры запроса. Требуются email и tg_id.",
|
||||
status=400,
|
||||
)
|
||||
|
||||
logger.info(f"Обработка запроса для нового клиента: email={email}, tg_id={tg_id}")
|
||||
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
|
||||
client_data = await conn.fetchrow(
|
||||
"SELECT tg_id, server_id FROM keys WHERE email = $1", email
|
||||
)
|
||||
|
||||
if not client_data:
|
||||
logger.warning(f"Клиент с email {email} не найден в базе.")
|
||||
return web.Response(
|
||||
text="❌ Клиент с таким email не найден.",
|
||||
status=404,
|
||||
)
|
||||
|
||||
stored_tg_id = client_data["tg_id"]
|
||||
cluster_name = client_data["server_id"]
|
||||
|
||||
if str(tg_id) != str(stored_tg_id):
|
||||
logger.warning(f"Неверный tg_id для клиента с email {email}.")
|
||||
return web.Response(
|
||||
text="❌ Неверные данные. Получите свой ключ в боте.",
|
||||
status=403,
|
||||
)
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
cluster_servers = servers.get(cluster_name, [])
|
||||
|
||||
urls = []
|
||||
for server in cluster_servers:
|
||||
server_subscription_url = f"{server['subscription_url']}/{email}"
|
||||
urls.append(server_subscription_url)
|
||||
|
||||
query_string = request.query_string
|
||||
logger.info(f"Извлечен query string: {query_string}")
|
||||
|
||||
combined_subscriptions = await combine_unique_lines(urls, tg_id, query_string)
|
||||
|
||||
base64_encoded = base64.b64encode(
|
||||
"\n".join(combined_subscriptions).encode("utf-8")
|
||||
).decode("utf-8")
|
||||
|
||||
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "inline",
|
||||
"profile-update-interval": "7",
|
||||
"profile-title": "base64:" + base64.b64encode(
|
||||
encoded_project_name.encode("utf-8")
|
||||
).decode("utf-8"),
|
||||
}
|
||||
|
||||
logger.info(f"Возвращаем объединенные подписки для email: {email}")
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
@@ -1,51 +1,68 @@
|
||||
import asyncpg
|
||||
import asyncio
|
||||
import uuid
|
||||
from config import DATABASE_URL, SERVERS, ADMIN_USERNAME, ADMIN_PASSWORD
|
||||
from auth import login_with_credentials, link_subscription
|
||||
from client import add_client
|
||||
from database import store_key, add_connection
|
||||
from handlers.texts import INSTRUCTIONS
|
||||
from datetime import datetime, timedelta
|
||||
from handlers.utils import generate_random_email, get_least_loaded_server
|
||||
from typing import Any
|
||||
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from client import add_client
|
||||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, PUBLIC_LINK, TOTAL_GB, TRIAL_TIME
|
||||
from database import get_servers_from_db, store_key, use_trial
|
||||
from handlers.texts import INSTRUCTIONS
|
||||
from handlers.utils import generate_random_email, get_least_loaded_cluster
|
||||
|
||||
|
||||
async def create_trial_key(tg_id: int):
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
server_id = await get_least_loaded_server(conn)
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
current_time = datetime.utcnow()
|
||||
async def create_trial_key(tg_id: int, session: Any):
|
||||
client_id = str(uuid.uuid4())
|
||||
email = generate_random_email()
|
||||
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
|
||||
instructions = INSTRUCTIONS
|
||||
result = {"key": public_link, "instructions": instructions, "email": email}
|
||||
current_time = datetime.utcnow()
|
||||
expiry_time = current_time + timedelta(days=TRIAL_TIME)
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
expiry_time = current_time + timedelta(days=1, hours=3)
|
||||
expiry_timestamp = int(expiry_time.timestamp() * 1000)
|
||||
|
||||
client_id = str(uuid.uuid4())
|
||||
email = generate_random_email()
|
||||
response = await add_client(
|
||||
session, server_id, client_id, email, tg_id,
|
||||
limit_ip=1, total_gb=0, expiry_time=expiry_timestamp,
|
||||
enable=True, flow="xtls-rprx-vision"
|
||||
clusters = await get_servers_from_db()
|
||||
|
||||
least_loaded_cluster = await get_least_loaded_cluster()
|
||||
|
||||
if least_loaded_cluster not in clusters:
|
||||
raise ValueError(f"Кластер {least_loaded_cluster} не найден в базе данных.")
|
||||
|
||||
servers_in_cluster = clusters[least_loaded_cluster]
|
||||
|
||||
tasks = []
|
||||
|
||||
for server_info in servers_in_cluster:
|
||||
tasks.append(
|
||||
add_client(
|
||||
AsyncApi(
|
||||
server_info["api_url"],
|
||||
username=ADMIN_USERNAME,
|
||||
password=ADMIN_PASSWORD,
|
||||
),
|
||||
client_id,
|
||||
email,
|
||||
tg_id,
|
||||
limit_ip=1,
|
||||
total_gb=TOTAL_GB,
|
||||
expiry_time=expiry_timestamp,
|
||||
enable=True,
|
||||
flow="xtls-rprx-vision",
|
||||
inbound_id=int(server_info["inbound_id"]),
|
||||
)
|
||||
)
|
||||
|
||||
if response.get("success"):
|
||||
# Генерация ссылки подписки
|
||||
connection_link = await link_subscription(email, server_id)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
existing_connection = await conn.fetchrow('SELECT * FROM connections WHERE tg_id = $1', tg_id)
|
||||
|
||||
if existing_connection:
|
||||
await conn.execute('UPDATE connections SET trial = 1 WHERE tg_id = $1', tg_id)
|
||||
else:
|
||||
await add_connection(tg_id, 0, 1)
|
||||
|
||||
await store_key(tg_id, client_id, email, expiry_timestamp, connection_link, server_id)
|
||||
|
||||
instructions = INSTRUCTIONS
|
||||
return {
|
||||
'key': connection_link,
|
||||
'instructions': instructions
|
||||
}
|
||||
else:
|
||||
return {'error': 'Не удалось добавить клиента на панель'}
|
||||
finally:
|
||||
await conn.close()
|
||||
await store_key(
|
||||
tg_id,
|
||||
client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
public_link,
|
||||
server_id=least_loaded_cluster,
|
||||
session=session,
|
||||
)
|
||||
await use_trial(tg_id, session)
|
||||
return result
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
from datetime import datetime, timedelta
|
||||
import asyncpg
|
||||
import asyncio
|
||||
from aiogram import Bot, Router
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
import logging
|
||||
from config import DATABASE_URL, ADMIN_USERNAME, ADMIN_PASSWORD, SERVERS
|
||||
from database import get_balance, update_key_expiry, delete_key
|
||||
from client import extend_client_key, delete_client
|
||||
from auth import login_with_credentials
|
||||
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED, KEY_RENEWAL_FAILED, KEY_DELETED, KEY_DELETION_FAILED
|
||||
from aiogram import Router, types
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
import asyncpg
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
DATABASE_URL,
|
||||
DEV_MODE,
|
||||
RENEWAL_PLANS,
|
||||
TOTAL_GB,
|
||||
TRIAL_TIME,
|
||||
)
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notification_time,
|
||||
delete_key,
|
||||
get_balance,
|
||||
get_servers_from_db,
|
||||
update_balance,
|
||||
update_key_expiry,
|
||||
)
|
||||
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
|
||||
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
class NotificationStates(StatesGroup):
|
||||
waiting_for_notification_text = State()
|
||||
|
||||
async def notify_expiring_keys(bot: Bot):
|
||||
conn = None
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("Подключение к базе данных успешно.")
|
||||
|
||||
current_time = datetime.utcnow().timestamp() * 1000
|
||||
threshold_time_10h = (datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000
|
||||
threshold_time_24h = (datetime.utcnow() + timedelta(days=1)).timestamp() * 1000
|
||||
|
||||
current_time = int(datetime.utcnow().timestamp() * 1000)
|
||||
threshold_time_10h = int(
|
||||
(datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000
|
||||
)
|
||||
threshold_time_24h = int(
|
||||
(datetime.utcnow() + timedelta(days=1)).timestamp() * 1000
|
||||
)
|
||||
|
||||
logger.info("Начало обработки уведомлений.")
|
||||
|
||||
await notify_inactive_trial_users(bot, conn)
|
||||
await asyncio.sleep(1)
|
||||
await check_online_users()
|
||||
await asyncio.sleep(1)
|
||||
await notify_10h_keys(bot, conn, current_time, threshold_time_10h)
|
||||
await asyncio.sleep(1) # Задержка между уведомлениями за 10 часов и 24 часа
|
||||
await asyncio.sleep(1)
|
||||
await notify_24h_keys(bot, conn, current_time, threshold_time_24h)
|
||||
await asyncio.sleep(1) # Задержка перед обработкой истекших ключей
|
||||
await asyncio.sleep(1)
|
||||
await handle_expired_keys(bot, conn, current_time)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомлений: {e}")
|
||||
@@ -44,144 +65,351 @@ async def notify_expiring_keys(bot: Bot):
|
||||
await conn.close()
|
||||
logger.info("Соединение с базой данных закрыто.")
|
||||
|
||||
|
||||
async def is_bot_blocked(bot: Bot, chat_id: int) -> bool:
|
||||
if DEV_MODE:
|
||||
return False
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id, bot.id)
|
||||
return member.status == 'left'
|
||||
blocked = member.status == "left"
|
||||
logger.info(
|
||||
f"Статус бота для пользователя {chat_id}: {'заблокирован' if blocked else 'активен'}"
|
||||
)
|
||||
return blocked
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке статуса бота у пользователя {chat_id}: {e}")
|
||||
return False
|
||||
logger.warning(
|
||||
f"Не удалось проверить статус бота для пользователя {chat_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: float, threshold_time_10h: float):
|
||||
records = await conn.fetch('''
|
||||
|
||||
async def notify_10h_keys(
|
||||
bot: Bot,
|
||||
conn: asyncpg.Connection,
|
||||
current_time: float,
|
||||
threshold_time_10h: float,
|
||||
):
|
||||
records = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE
|
||||
''', threshold_time_10h, current_time)
|
||||
""",
|
||||
threshold_time_10h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.")
|
||||
for record in records:
|
||||
tg_id = record['tg_id']
|
||||
email = record['email']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
message = KEY_EXPIRY_10H.format(server_id=SERVERS[server_id]['name'], email=email, expiry_date=expiry_date)
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
if not await is_bot_blocked(bot, tg_id):
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "Ключ истек"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"{time_left.days}"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"{hours_left}"
|
||||
|
||||
message = KEY_EXPIRY_10H.format(
|
||||
email=email,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
days_left_message=days_left_message,
|
||||
price=RENEWAL_PLANS["1"]["price"],
|
||||
)
|
||||
|
||||
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Продлить VPN', callback_data=f'renew_key|{record["client_id"]}')],
|
||||
])
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
keyboard.button(
|
||||
text="🔄 Продлить VPN", callback_data=f"renew_key|{email}"
|
||||
)
|
||||
keyboard.button(text="💳 Пополнить баланс", callback_data="pay")
|
||||
keyboard.button(text="👤 Личный кабинет", callback_data="profile")
|
||||
keyboard.adjust(1)
|
||||
keyboard = keyboard.as_markup()
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
continue
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления пользователю {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
await conn.execute('UPDATE keys SET notified = TRUE WHERE client_id = $1', record['client_id'])
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE client_id = $1",
|
||||
record["client_id"],
|
||||
)
|
||||
logger.info(f"Обновлено поле notified для клиента {record['client_id']}.")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: float, threshold_time_24h: float):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_24h_keys(
|
||||
bot: Bot,
|
||||
conn: asyncpg.Connection,
|
||||
current_time: float,
|
||||
threshold_time_24h: float,
|
||||
):
|
||||
logger.info("Проверка истекших ключей...")
|
||||
|
||||
records_24h = await conn.fetch('''
|
||||
records_24h = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
|
||||
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE
|
||||
''', threshold_time_24h, current_time)
|
||||
""",
|
||||
threshold_time_24h,
|
||||
current_time,
|
||||
)
|
||||
|
||||
logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.")
|
||||
for record in records_24h:
|
||||
tg_id = record['tg_id']
|
||||
email = record['email']
|
||||
expiry_time = record['expiry_time']
|
||||
server_id = record['server_id']
|
||||
tg_id = record["tg_id"]
|
||||
email = record["email"]
|
||||
expiry_time = record["expiry_time"]
|
||||
|
||||
time_left = (expiry_time / 1000) - datetime.utcnow().timestamp()
|
||||
hours_left = max(0, int(time_left // 3600))
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')
|
||||
balance = await get_balance(tg_id)
|
||||
if time_left.total_seconds() <= 0:
|
||||
days_left_message = "Ключ истек"
|
||||
elif time_left.days > 0:
|
||||
days_left_message = f"{time_left.days}"
|
||||
else:
|
||||
hours_left = time_left.seconds // 3600
|
||||
days_left_message = f"{hours_left}"
|
||||
|
||||
message_24h = KEY_EXPIRY_24H.format(server_id=SERVERS[server_id]['name'], email=email, hours_left=hours_left, expiry_date=expiry_date, balance=balance)
|
||||
message_24h = KEY_EXPIRY_24H.format(
|
||||
email=email,
|
||||
days_left_message=days_left_message,
|
||||
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
|
||||
if not await is_bot_blocked(bot, tg_id):
|
||||
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Продлить VPN', callback_data=f'renew_key|{record["client_id"]}')],
|
||||
])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Продлить VPN",
|
||||
callback_data=f"renew_key|{email}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="pay",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет",
|
||||
callback_data="profile",
|
||||
)
|
||||
)
|
||||
keyboard = builder.as_markup()
|
||||
await bot.send_message(tg_id, message_24h, reply_markup=keyboard)
|
||||
logger.info(f"Уведомление за 24 часа отправлено пользователю {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления за 24 часа пользователю {tg_id}: {e}")
|
||||
continue
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления за 24 часа пользователю {tg_id}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified_24h = TRUE WHERE client_id = $1",
|
||||
record["client_id"],
|
||||
)
|
||||
logger.info(
|
||||
f"Обновлено поле notified_24h для клиента {record['client_id']}."
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
inactive_trial_users = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, username FROM users
|
||||
WHERE tg_id IN (
|
||||
SELECT tg_id FROM connections
|
||||
WHERE trial = 0
|
||||
) AND tg_id NOT IN (
|
||||
SELECT DISTINCT tg_id FROM keys
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
|
||||
|
||||
for user in inactive_trial_users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user.get("username", "Пользователь")
|
||||
|
||||
try:
|
||||
can_notify = await check_notification_time(
|
||||
tg_id, "inactive_trial", hours=24, session=conn
|
||||
)
|
||||
|
||||
if can_notify and not await is_bot_blocked(bot, tg_id):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🚀 Активировать пробный период",
|
||||
callback_data="create_key",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="profile"
|
||||
)
|
||||
)
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = (
|
||||
f"👋 Привет, {username}!\n\n"
|
||||
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
|
||||
"🕒 Не упусти возможность попробовать наш VPN прямо сейчас.\n\n"
|
||||
"💡 Нажми на кнопку ниже, чтобы активировать пробный доступ."
|
||||
)
|
||||
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления неактивному пользователю {tg_id}: {e}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await conn.execute('UPDATE keys SET notified_24h = TRUE WHERE client_id = $1', record['client_id'])
|
||||
logger.info(f"Обновлено поле notified_24h для клиента {record['client_id']}.")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float):
|
||||
logger.info("Проверка истекших ключей...")
|
||||
|
||||
current_time = int(current_time)
|
||||
expiring_keys = await conn.fetch('''
|
||||
SELECT tg_id, client_id, expiry_time, server_id, email FROM keys
|
||||
expiring_keys = await conn.fetch(
|
||||
"""
|
||||
SELECT tg_id, client_id, expiry_time, email FROM keys
|
||||
WHERE expiry_time <= $1
|
||||
''', current_time)
|
||||
|
||||
""",
|
||||
current_time,
|
||||
)
|
||||
logger.info(f"current_time {current_time}")
|
||||
logger.info(f"Найдено {len(expiring_keys)} истекающих ключей.")
|
||||
|
||||
for record in expiring_keys:
|
||||
tg_id = record['tg_id']
|
||||
client_id = record['client_id']
|
||||
balance = await get_balance(tg_id)
|
||||
server_id = record['server_id']
|
||||
email = record['email']
|
||||
|
||||
logger.info(f"Проверка баланса для клиента {tg_id}: {balance}.")
|
||||
await asyncio.gather(*[process_key(record, bot, conn) for record in expiring_keys])
|
||||
|
||||
button_profile = types.InlineKeyboardButton(text='👤 Мой профиль', callback_data='view_profile')
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[button_profile]])
|
||||
|
||||
if balance >= 100:
|
||||
new_expiry_time = int((datetime.utcnow() + timedelta(days=30)).timestamp() * 1000)
|
||||
async def process_key(record, bot, conn):
|
||||
tg_id = record["tg_id"]
|
||||
client_id = record["client_id"]
|
||||
email = record["email"]
|
||||
balance = await get_balance(tg_id)
|
||||
expiry_time = record["expiry_time"]
|
||||
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
|
||||
current_date = datetime.utcnow()
|
||||
time_left = expiry_date - current_date
|
||||
|
||||
logger.info(
|
||||
f"Время истечения ключа: {expiry_time} (дата: {expiry_date}), Текущее время: {current_date}, Оставшееся время: {time_left}"
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👤 Личный кабинет", callback_data="profile"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
if balance >= RENEWAL_PLANS["1"]["price"]:
|
||||
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
|
||||
new_expiry_time = int(
|
||||
(datetime.utcnow() + timedelta(days=30)).timestamp() * 1000
|
||||
)
|
||||
await update_key_expiry(client_id, new_expiry_time)
|
||||
logger.info(f"Ключ для клиента {tg_id} продлен до {datetime.utcfromtimestamp(new_expiry_time / 1000).strftime('%Y-%m-%d %H:%M:%S')}.")
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success = await extend_client_key(session, server_id, tg_id, client_id, email, new_expiry_time)
|
||||
if success:
|
||||
try:
|
||||
await bot.send_message(tg_id, KEY_RENEWED, reply_markup=keyboard)
|
||||
logger.info(f"Ключ для пользователя {tg_id} успешно продлен на месяц.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления о продлении ключа пользователю {tg_id}: {e}")
|
||||
else:
|
||||
try:
|
||||
await bot.send_message(tg_id, KEY_RENEWAL_FAILED, reply_markup=keyboard)
|
||||
logger.error(f"Не удалось продлить ключ для пользователя {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления о неудачном продлении ключа пользователю {tg_id}: {e}")
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
for cluster_id in servers:
|
||||
await renew_key_in_cluster(
|
||||
cluster_id, email, client_id, new_expiry_time, TOTAL_GB
|
||||
)
|
||||
logger.info(
|
||||
f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}."
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE keys
|
||||
SET notified = FALSE, notified_24h = FALSE
|
||||
WHERE client_id = $1
|
||||
""",
|
||||
client_id,
|
||||
)
|
||||
logger.info(
|
||||
f"Флаги notified и notified_24 сброшены для клиента с ID {client_id}."
|
||||
)
|
||||
try:
|
||||
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
|
||||
logger.info(
|
||||
f"Уведомление об успешном продлении отправлено клиенту {tg_id}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления клиенту {tg_id}: {e}")
|
||||
|
||||
else:
|
||||
await delete_key(client_id)
|
||||
logger.info(f"Ключ для клиента {tg_id} удален из-за недостаточного баланса.")
|
||||
|
||||
session = await login_with_credentials(server_id, ADMIN_USERNAME, ADMIN_PASSWORD)
|
||||
success = await delete_client(session, server_id, client_id)
|
||||
if success:
|
||||
try:
|
||||
await bot.send_message(tg_id, KEY_DELETED, reply_markup=keyboard)
|
||||
logger.info(f"Ключ для пользователя {tg_id} удален.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления об удалении ключа пользователю {tg_id}: {e}")
|
||||
else:
|
||||
try:
|
||||
await bot.send_message(tg_id, KEY_DELETION_FAILED, reply_markup=keyboard)
|
||||
logger.error(f"Не удалось удалить ключ для пользователя {tg_id}.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления о неудачном удалении ключа пользователю {tg_id}: {e}")
|
||||
message_expired = "Ваша подписка истекла и была удалена. Получите новую через личный кабинет"
|
||||
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
await bot.send_message(
|
||||
tg_id, text=message_expired, reply_markup=keyboard
|
||||
)
|
||||
logger.info(
|
||||
f"Уведомление об истечении подписки и удалении ключа отправлено пользователю {tg_id}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка при отправке уведомления об истечении подписки пользователю {tg_id}: {e}"
|
||||
)
|
||||
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
for cluster_id in servers:
|
||||
await delete_key_from_cluster(cluster_id, email, client_id)
|
||||
logger.info(f"Клиент {client_id} удален из кластера {cluster_id}.")
|
||||
|
||||
await delete_key(client_id)
|
||||
logger.info(f"Ключ для клиента с ID {client_id} удален из базы данных.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке ключа для клиента {tg_id}: {e}")
|
||||
|
||||
|
||||
async def check_online_users():
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
for cluster_id, cluster in servers.items():
|
||||
for server_id, server in enumerate(cluster):
|
||||
xui = AsyncApi(
|
||||
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
|
||||
)
|
||||
await xui.login()
|
||||
try:
|
||||
online_users = len(await xui.client.online())
|
||||
logger.info(
|
||||
f"Сервер '{server['server_name']}' доступен, текущее количество активных пользователей: {online_users}."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось проверить пользователей на сервере {server_id}: {e}"
|
||||
)
|
||||
|
||||
@@ -1,277 +1,77 @@
|
||||
import logging
|
||||
import uuid
|
||||
from aiogram import F, Router
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from aiogram import Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiohttp import web
|
||||
from yookassa import Configuration, Payment
|
||||
|
||||
from bot import bot
|
||||
from config import YOOKASSA_SECRET_KEY, YOOKASSA_SHOP_ID
|
||||
from database import (add_connection, check_connection_exists, get_key_count,
|
||||
update_balance)
|
||||
from handlers.profile import process_callback_view_profile
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
DONATIONS_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
Configuration.account_id = YOOKASSA_SHOP_ID
|
||||
Configuration.secret_key = YOOKASSA_SECRET_KEY
|
||||
@router.callback_query(F.data == "pay")
|
||||
async def handle_pay(callback_query: CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
logging.debug(f"Account ID: {YOOKASSA_SHOP_ID}")
|
||||
logging.debug(f"Secret Key: {YOOKASSA_SECRET_KEY}")
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount = State()
|
||||
waiting_for_payment_confirmation = State()
|
||||
entering_custom_amount = State()
|
||||
|
||||
async def send_message_with_deletion(chat_id, text, reply_markup=None, state=None, message_key='last_message_id'):
|
||||
if state:
|
||||
try:
|
||||
state_data = await state.get_data()
|
||||
previous_message_id = state_data.get(message_key)
|
||||
|
||||
if previous_message_id:
|
||||
await bot.delete_message(chat_id=chat_id, message_id=previous_message_id)
|
||||
|
||||
sent_message = await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
await state.update_data({message_key: sent_message.message_id})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при удалении/отправке сообщения: {e}")
|
||||
return None
|
||||
|
||||
return sent_message
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'replenish_balance')
|
||||
async def process_callback_replenish_balance(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0)
|
||||
|
||||
amount_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[0]['text'], callback_data=PAYMENT_OPTIONS[0]['callback_data']),
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[1]['text'], callback_data=PAYMENT_OPTIONS[1]['callback_data'])
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[2]['text'], callback_data=PAYMENT_OPTIONS[2]['callback_data']),
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[3]['text'], callback_data=PAYMENT_OPTIONS[3]['callback_data'])
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[4]['text'], callback_data=PAYMENT_OPTIONS[4]['callback_data'])
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=PAYMENT_OPTIONS[5]['text'], callback_data=PAYMENT_OPTIONS[5]['callback_data'])
|
||||
]
|
||||
])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=amount_keyboard
|
||||
)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'back_to_profile')
|
||||
async def back_to_profile_handler(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
|
||||
@router.callback_query(lambda c: c.data.startswith('amount_'))
|
||||
async def process_amount_selection(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
data = callback_query.data.split('_', 1)
|
||||
|
||||
if len(data) != 2:
|
||||
await send_message_with_deletion(callback_query.from_user.id, "Неверные данные для выбора суммы.", state=state, message_key='amount_error_message_id')
|
||||
return
|
||||
|
||||
amount_str = data[1]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
except ValueError:
|
||||
await send_message_with_deletion(callback_query.from_user.id, "Некорректная сумма.", state=state, message_key='amount_error_message_id')
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation)
|
||||
|
||||
state_data = await state.get_data()
|
||||
customer_name = callback_query.from_user.full_name
|
||||
customer_id = callback_query.from_user.id
|
||||
|
||||
customer_email = f"{customer_id}@solo.net"
|
||||
|
||||
payment = Payment.create({
|
||||
"amount": {
|
||||
"value": str(amount),
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "https://pocomacho.ru/"
|
||||
},
|
||||
"capture": True,
|
||||
"description": "Пополнение баланса",
|
||||
"receipt": {
|
||||
"customer": {
|
||||
"full_name": customer_name,
|
||||
"email": customer_email,
|
||||
"phone": "79000000000"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"description": "Пополнение баланса",
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(amount),
|
||||
"currency": "RUB"
|
||||
},
|
||||
"vat_code": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"user_id": customer_id
|
||||
}
|
||||
}, uuid.uuid4())
|
||||
|
||||
if payment['status'] == 'pending':
|
||||
payment_url = payment['confirmation']['confirmation_url']
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='Пополнить', url=payment_url)],
|
||||
[InlineKeyboardButton(text='⬅️ Назад', callback_data='back_to_profile')]
|
||||
])
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=confirm_keyboard
|
||||
if YOOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💳 ЮКасса: быстрый перевод",
|
||||
callback_data="pay_yookassa",
|
||||
)
|
||||
)
|
||||
else:
|
||||
await send_message_with_deletion(callback_query.from_user.id, "Ошибка при создании платежа.", state=state)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
async def send_payment_success_notification(user_id: int, amount: float):
|
||||
try:
|
||||
profile_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='Перейти в профиль', callback_data='view_profile')]
|
||||
])
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"Ваш баланс успешно пополнен на {amount} рублей. Спасибо за оплату!",
|
||||
reply_markup=profile_keyboard
|
||||
if YOOMONEY_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💳 ЮМани: оплата по карте",
|
||||
callback_data="pay_yoomoney",
|
||||
)
|
||||
)
|
||||
if FREEKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🌐 FreeKassa: множество способов",
|
||||
callback_data="pay_freekassa",
|
||||
)
|
||||
)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 CryptoBot: криптовалюта",
|
||||
callback_data="pay_cryptobot",
|
||||
)
|
||||
)
|
||||
if STARS_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ Оплата Звездами",
|
||||
callback_data="pay_stars",
|
||||
)
|
||||
)
|
||||
if ROBOKASSA_ENABLE:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⭐ RoboKassa",
|
||||
callback_data="pay_robokassa",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
|
||||
async def payment_webhook(request):
|
||||
event = await request.json()
|
||||
builder.row(InlineKeyboardButton(text="🎟️ Активировать купон", callback_data="activate_coupon"))
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
|
||||
logging.debug(f"Webhook event received: {event}")
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
if event['event'] == 'payment.succeeded':
|
||||
user_id_str = event['object']['metadata']['user_id']
|
||||
amount_str = event['object']['amount']['value']
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
amount = float(amount_str)
|
||||
|
||||
logging.debug(f"Payment succeeded for user_id: {user_id}, amount: {amount}")
|
||||
await update_balance(user_id, amount)
|
||||
|
||||
await send_payment_success_notification(user_id, amount)
|
||||
|
||||
except ValueError as e:
|
||||
logging.error(f"Ошибка конвертации user_id или amount: {e}")
|
||||
return web.Response(status=400)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'enter_custom_amount')
|
||||
async def process_enter_custom_amount(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await callback_query.message.edit_text(
|
||||
text="Введите сумму пополнения:"
|
||||
await callback_query.message.answer(
|
||||
"💸 <b>Выберите удобный способ пополнения баланса:</b>\n\n"
|
||||
"• Быстро и безопасно\n"
|
||||
"• Поддержка разных платежных систем\n"
|
||||
"• Моментальное зачисление средств 🚀",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(ReplenishBalanceState.entering_custom_amount)
|
||||
await callback_query.answer()
|
||||
|
||||
@router.message(State(ReplenishBalanceState.entering_custom_amount))
|
||||
async def process_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
if message.text.isdigit():
|
||||
amount = int(message.text)
|
||||
if amount <= 0:
|
||||
await message.answer("Сумма должна быть больше нуля. Пожалуйста, введите сумму еще раз:")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(ReplenishBalanceState.waiting_for_payment_confirmation)
|
||||
|
||||
try:
|
||||
payment = Payment.create({
|
||||
"amount": {
|
||||
"value": str(amount),
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "https://pocomacho.ru/"
|
||||
},
|
||||
"capture": True,
|
||||
"description": "Пополнение баланса",
|
||||
"receipt": {
|
||||
"customer": {
|
||||
"full_name": message.from_user.full_name,
|
||||
"email": f"{message.from_user.id}@solo.net",
|
||||
"phone": "79000000000"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"description": "Пополнение баланса",
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(amount),
|
||||
"currency": "RUB"
|
||||
},
|
||||
"vat_code": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"user_id": message.from_user.id
|
||||
}
|
||||
}, uuid.uuid4())
|
||||
|
||||
if payment['status'] == 'pending':
|
||||
payment_url = payment['confirmation']['confirmation_url']
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='Пополнить', url=payment_url)],
|
||||
[InlineKeyboardButton(text='⬅️ Назад', callback_data='back_to_profile')]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей.",
|
||||
reply_markup=confirm_keyboard
|
||||
)
|
||||
else:
|
||||
await message.answer("Ошибка при создании платежа.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Ошибка при создании платежа: {e}")
|
||||
await message.answer("Произошла ошибка при создании платежа.")
|
||||
else:
|
||||
await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
__all__ = ("router",)
|
||||
|
||||
from aiogram import Router
|
||||
|
||||
from config import (
|
||||
CRYPTO_BOT_ENABLE,
|
||||
FREEKASSA_ENABLE,
|
||||
ROBOKASSA_ENABLE,
|
||||
STARS_ENABLE,
|
||||
YOOKASSA_ENABLE,
|
||||
YOOMONEY_ENABLE,
|
||||
)
|
||||
|
||||
from .cryprobot_pay import router as cryprobot_router
|
||||
from .gift import router as gift_router
|
||||
from .robokassa_pay import router as robokassa_router
|
||||
from .stars_pay import router as stars_router
|
||||
from .yookassa_pay import router as yookassa_router
|
||||
from .yoomoney_pay import router as yoomoney_router
|
||||
|
||||
router = Router(name="payments_main_router")
|
||||
|
||||
if YOOKASSA_ENABLE:
|
||||
router.include_router(yookassa_router)
|
||||
if YOOMONEY_ENABLE:
|
||||
router.include_router(yoomoney_router)
|
||||
if ROBOKASSA_ENABLE:
|
||||
router.include_router(robokassa_router)
|
||||
if CRYPTO_BOT_ENABLE:
|
||||
router.include_router(cryprobot_router)
|
||||
if STARS_ENABLE:
|
||||
router.include_router(stars_router)
|
||||
|
||||
router.include_router(gift_router)
|
||||
@@ -0,0 +1,277 @@
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiohttp import web
|
||||
from robokassa import HashAlgorithm, Robokassa
|
||||
|
||||
from config import (
|
||||
ROBOKASSA_ENABLE,
|
||||
ROBOKASSA_LOGIN,
|
||||
ROBOKASSA_PASSWORD1,
|
||||
ROBOKASSA_PASSWORD2,
|
||||
ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
from database import (
|
||||
add_connection,
|
||||
add_payment,
|
||||
check_connection_exists,
|
||||
get_key_count,
|
||||
update_balance,
|
||||
)
|
||||
from handlers.payments.utils import send_payment_success_notification
|
||||
from handlers.texts import PAYMENT_OPTIONS
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_amount_robokassa = State()
|
||||
waiting_for_payment_confirmation_robokassa = State()
|
||||
|
||||
|
||||
if ROBOKASSA_ENABLE:
|
||||
robokassa = Robokassa(
|
||||
merchant_login=ROBOKASSA_LOGIN,
|
||||
password1=ROBOKASSA_PASSWORD1,
|
||||
password2=ROBOKASSA_PASSWORD2,
|
||||
algorithm=HashAlgorithm.md5,
|
||||
is_test=ROBOKASSA_TEST_MODE,
|
||||
)
|
||||
|
||||
logger.info("Robokassa initialized with login: {}", ROBOKASSA_LOGIN)
|
||||
|
||||
|
||||
def generate_payment_link(amount, inv_id, description, tg_id):
|
||||
"""Генерация ссылки на оплату."""
|
||||
logger.debug(
|
||||
f"Generating payment link for amount: {amount}, inv_id: {inv_id}, description: {description}"
|
||||
)
|
||||
payment_link = robokassa._payment.link.generate_by_script(
|
||||
out_sum=amount,
|
||||
inv_id=inv_id,
|
||||
description="пополнение баланса",
|
||||
id=f"{tg_id}",
|
||||
)
|
||||
logger.info(f"Generated payment link: {payment_link}")
|
||||
return payment_link
|
||||
|
||||
|
||||
@router.callback_query(F.data == "pay_robokassa")
|
||||
async def process_callback_pay_robokassa(
|
||||
callback_query: types.CallbackQuery, state: FSMContext, session: Any
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} initiated Robokassa payment.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for i in range(0, len(PAYMENT_OPTIONS), 2):
|
||||
if i + 1 < len(PAYMENT_OPTIONS):
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i + 1]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i + 1]["callback_data"]}',
|
||||
),
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT_OPTIONS[i]["text"],
|
||||
callback_data=f'robokassa_amount|{PAYMENT_OPTIONS[i]["callback_data"]}',
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💰 Ввести свою сумму",
|
||||
callback_data="enter_custom_amount_robokassa",
|
||||
)
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="pay"))
|
||||
|
||||
key_count = await get_key_count(tg_id)
|
||||
|
||||
if key_count == 0:
|
||||
exists = await check_connection_exists(tg_id)
|
||||
if not exists:
|
||||
await add_connection(tg_id, balance=0.0, trial=0, session=session)
|
||||
logger.info(f"Created new connection for user {tg_id} with balance 0.0.")
|
||||
|
||||
await callback_query.message.answer(
|
||||
text="Выберите сумму пополнения:",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await state.set_state(ReplenishBalanceState.choosing_amount_robokassa)
|
||||
logger.info(f"Displayed amount selection for user {tg_id}.")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("robokassa_amount|"))
|
||||
async def process_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
logger.info(f"Получены данные callback_data: {callback_query.data}")
|
||||
|
||||
data = callback_query.data.split("|")
|
||||
if len(data) != 3 or data[1] != "amount":
|
||||
logger.error("Ошибка: callback_data не соответствует формату.")
|
||||
await callback_query.message.answer("Ошибка: данные повреждены.")
|
||||
return
|
||||
|
||||
amount_str = data[2]
|
||||
try:
|
||||
amount = int(amount_str)
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректное значение суммы: {amount_str}. Ошибка: {e}")
|
||||
await callback_query.message.answer("Некорректная сумма.")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
logger.info(f"User {callback_query.message.chat.id} selected amount: {amount}.")
|
||||
inv_id = 0
|
||||
|
||||
tg_id = callback_query.message.chat.id
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
|
||||
logger.info(f"Payment URL for user {callback_query.message.chat.id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
logger.info(f"Payment link sent to user {callback_query.message.chat.id}.")
|
||||
|
||||
|
||||
async def robokassa_webhook(request):
|
||||
"""Обработка webhook-уведомлений от Robokassa с учетом shp_id."""
|
||||
try:
|
||||
params = await request.post()
|
||||
|
||||
logger.info(f"Received webhook params: {params}")
|
||||
|
||||
amount = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
shp_id = params.get("shp_id")
|
||||
signature_value = params.get("SignatureValue")
|
||||
|
||||
logger.info(
|
||||
f"OutSum: {amount}, InvId: {inv_id}, shp_id: {shp_id}, SignatureValue: {signature_value}"
|
||||
)
|
||||
|
||||
if not check_payment_signature(params):
|
||||
logger.error("Неверная подпись или данные запроса.")
|
||||
return web.Response(status=400)
|
||||
|
||||
if not amount or not inv_id or not shp_id:
|
||||
logger.error("Отсутствуют обязательные параметры.")
|
||||
return web.Response(status=400)
|
||||
|
||||
tg_id = shp_id
|
||||
|
||||
logger.info(f"Processing payment for user {tg_id} with amount {amount}.")
|
||||
|
||||
await update_balance(int(tg_id), float(amount))
|
||||
await send_payment_success_notification(tg_id, float(amount))
|
||||
|
||||
await add_payment(int(tg_id), float(amount), "robokassa")
|
||||
|
||||
logger.info(f"Payment successful. Balance updated for user {tg_id}.")
|
||||
|
||||
return web.Response(text=f"OK{inv_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing webhook: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
def check_payment_signature(params):
|
||||
"""Проверка подписи запроса от Robokassa с учетом shp_id."""
|
||||
out_sum = params.get("OutSum")
|
||||
inv_id = params.get("InvId")
|
||||
signature_value = params.get("SignatureValue")
|
||||
shp_id = params.get("shp_id")
|
||||
|
||||
signature_string = f"{out_sum}:{inv_id}:{ROBOKASSA_PASSWORD2}:shp_id={shp_id}"
|
||||
|
||||
logger.info(f"Signature string before hashing: {signature_string}")
|
||||
|
||||
expected_signature = (
|
||||
hashlib.md5(signature_string.encode("utf-8")).hexdigest().upper()
|
||||
)
|
||||
|
||||
logger.info(f"Expected signature: {expected_signature}")
|
||||
logger.info(f"Received signature: {signature_value}")
|
||||
|
||||
return signature_value.upper() == expected_signature.upper()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "enter_custom_amount_robokassa")
|
||||
async def process_custom_amount_selection(
|
||||
callback_query: types.CallbackQuery, state: FSMContext
|
||||
):
|
||||
tg_id = callback_query.message.chat.id
|
||||
logger.info(f"User {tg_id} chose to enter a custom amount.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="pay_robokassa"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
"Пожалуйста, введите сумму пополнения.",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
await state.set_state(
|
||||
ReplenishBalanceState.waiting_for_payment_confirmation_robokassa
|
||||
)
|
||||
|
||||
|
||||
@router.message(ReplenishBalanceState.waiting_for_payment_confirmation_robokassa)
|
||||
async def handle_custom_amount_input(message: types.Message, state: FSMContext):
|
||||
tg_id = message.chat.id
|
||||
logger.info(f"User {tg_id} entered custom amount: {message.text}")
|
||||
inv_id = 0
|
||||
|
||||
try:
|
||||
amount = int(message.text)
|
||||
if amount <= 0:
|
||||
raise ValueError("Сумма должна быть положительным числом.")
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
|
||||
payment_url = generate_payment_link(amount, inv_id, "Пополнение баланса", tg_id)
|
||||
|
||||
logger.info(f"Generated payment link for user {tg_id}: {payment_url}")
|
||||
|
||||
confirm_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Оплатить", url=payment_url)],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="pay_robokassa")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text=f"Вы выбрали пополнение на {amount} рублей. Для оплаты перейдите по ссылке ниже:",
|
||||
reply_markup=confirm_keyboard,
|
||||
)
|
||||
await state.clear()
|
||||
except ValueError as e:
|
||||
logger.error(f"Некорректная сумма от пользователя {tg_id}: {e}")
|
||||
await message.answer(
|
||||
text="Введите корректную сумму в рублях (целое положительное число)."
|
||||
)
|
||||
|
Before Width: | Height: | Size: 32 KiB |
@@ -1,94 +1,151 @@
|
||||
from aiogram import Router, types
|
||||
import os
|
||||
|
||||
from aiogram import F, Router, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from bot import bot
|
||||
from config import NEWS_MESSAGE, RENEWAL_PLANS
|
||||
from database import get_balance, get_key_count, get_referral_stats
|
||||
from handlers.texts import profile_message_send, invite_message_send, CHANNEL_LINK, get_referral_link
|
||||
|
||||
|
||||
class ReplenishBalanceState(StatesGroup):
|
||||
choosing_transfer_method = State()
|
||||
waiting_for_admin_confirmation = State()
|
||||
from handlers.buttons.profile import ADD_SUB, GIFTS, INSTRUCTIONS, INVITE, MAIN_MENU, MY_SUBS, PAYMENT, TARRIFS
|
||||
from handlers.texts import get_referral_link, invite_message_send, profile_message_send
|
||||
|
||||
router = Router()
|
||||
|
||||
async def process_callback_view_profile(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
tg_id = callback_query.from_user.id
|
||||
username = callback_query.from_user.full_name
|
||||
|
||||
try:
|
||||
key_count = await get_key_count(tg_id)
|
||||
balance = await get_balance(tg_id)
|
||||
if balance is None:
|
||||
balance = 0
|
||||
@router.callback_query(F.data == "profile")
|
||||
@router.message(F.text == "/profile")
|
||||
async def process_callback_view_profile(
|
||||
callback_query_or_message: types.Message | types.CallbackQuery, state: FSMContext, admin: bool
|
||||
):
|
||||
if isinstance(callback_query_or_message, types.CallbackQuery):
|
||||
chat_id = callback_query_or_message.message.chat.id
|
||||
username = callback_query_or_message.from_user.full_name
|
||||
is_callback = True
|
||||
elif isinstance(callback_query_or_message, types.Message):
|
||||
chat_id = callback_query_or_message.chat.id
|
||||
username = callback_query_or_message.from_user.full_name
|
||||
is_callback = False
|
||||
|
||||
profile_message = (
|
||||
profile_message_send(username, tg_id, balance, key_count)
|
||||
)
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
key_count = await get_key_count(chat_id)
|
||||
balance = await get_balance(chat_id)
|
||||
if balance is None:
|
||||
balance = 0
|
||||
|
||||
profile_message += (
|
||||
f"<b>Обязательно подпишитесь на канал</b> <a href='{CHANNEL_LINK}'>здесь</a>\n"
|
||||
)
|
||||
|
||||
if key_count == 0:
|
||||
profile_message += "\n<i>Нажмите ➕Устройство снизу чтобы добавить устройство в VPN</i>"
|
||||
|
||||
button_create_key = InlineKeyboardButton(text='➕ Устройство', callback_data='create_key')
|
||||
button_view_keys = InlineKeyboardButton(text='📱 Мои устройства', callback_data='view_keys')
|
||||
button_replenish_balance = InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='replenish_balance')
|
||||
button_invite = InlineKeyboardButton(text='👥 Пригласить', callback_data='invite')
|
||||
button_instructions = InlineKeyboardButton(text='📘 Инструкции', callback_data='instructions')
|
||||
button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='back_to_menu')
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[button_create_key, button_view_keys],
|
||||
[button_replenish_balance],
|
||||
[button_invite, button_instructions],
|
||||
[button_back]
|
||||
])
|
||||
profile_message = profile_message_send(username, chat_id, int(balance), key_count)
|
||||
|
||||
except Exception as e:
|
||||
profile_message = f"❗️ Ошибка при получении данных профиля: {e}"
|
||||
keyboard = None
|
||||
|
||||
await callback_query.message.delete()
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=profile_message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard
|
||||
if key_count == 0:
|
||||
profile_message += "\n<pre>🔧 <i>Нажмите кнопку ➕ Устройство, чтобы настроить VPN-подключение</i></pre>"
|
||||
else:
|
||||
profile_message += f"\n<pre> <i>{NEWS_MESSAGE}</i></pre>"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"),
|
||||
InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys"),
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=PAYMENT,
|
||||
callback_data="pay",
|
||||
)
|
||||
)
|
||||
builder.row()
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=INVITE, callback_data="invite"),
|
||||
InlineKeyboardButton(text=GIFTS, callback_data="gifts"),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=TARRIFS, callback_data="view_tariffs"),
|
||||
InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions"),
|
||||
)
|
||||
if admin:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔧 Администратор", callback_data="admin")
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="start"))
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'invite')
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
if is_callback:
|
||||
await callback_query_or_message.message.answer_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename="pic.jpg"),
|
||||
caption=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query_or_message.answer_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename="pic.jpg"),
|
||||
caption=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
if is_callback:
|
||||
await callback_query_or_message.message.answer(
|
||||
text=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query_or_message.answer(
|
||||
text=profile_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.callback_query(F.data == "view_tariffs")
|
||||
async def view_tariffs_handler(callback_query: types.CallbackQuery):
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
image_path = os.path.join("img", "tariffs.jpg")
|
||||
|
||||
tariffs_message = "<b>🚀 Доступные тарифы VPN:</b>\n\n" + "\n".join(
|
||||
[
|
||||
f"{months} {'месяц' if months == '1' else 'месяца' if int(months) in [2, 3, 4] else 'месяцев'}: "
|
||||
f"{RENEWAL_PLANS[months]['price']} "
|
||||
f"{'💳' if months == '1' else '🌟' if months == '3' else '🔥' if months == '6' else '🚀'} рублей"
|
||||
for months in sorted(RENEWAL_PLANS.keys(), key=int)
|
||||
]
|
||||
)
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await callback_query.message.answer_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename="tariffs.jpg"),
|
||||
caption=tariffs_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
text=tariffs_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "invite")
|
||||
async def invite_handler(callback_query: types.CallbackQuery):
|
||||
tg_id = callback_query.from_user.id
|
||||
referral_link = get_referral_link(tg_id)
|
||||
|
||||
referral_stats = await get_referral_stats(tg_id)
|
||||
|
||||
invite_message = (
|
||||
invite_message_send(referral_link,referral_stats)
|
||||
)
|
||||
|
||||
button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='view_profile')
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[[button_back]])
|
||||
chat_id = callback_query.message.chat.id
|
||||
referral_link = get_referral_link(chat_id)
|
||||
|
||||
await callback_query.message.delete()
|
||||
referral_stats = await get_referral_stats(chat_id)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=tg_id,
|
||||
text=invite_message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard
|
||||
)
|
||||
invite_message = invite_message_send(referral_link, referral_stats)
|
||||
|
||||
await callback_query.answer()
|
||||
image_path = os.path.join("img", "pic_invite.jpg")
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'view_profile')
|
||||
async def view_profile_handler(callback_query: types.CallbackQuery, state: FSMContext):
|
||||
await process_callback_view_profile(callback_query, state)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_file:
|
||||
await callback_query.message.answer_photo(
|
||||
photo=BufferedInputFile(image_file.read(), filename="pic_invite.jpg"),
|
||||
caption=invite_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await callback_query.message.answer(
|
||||
text=invite_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
@@ -1,122 +1,252 @@
|
||||
import os
|
||||
from aiogram import Router
|
||||
from typing import Any
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import (BufferedInputFile, CallbackQuery,
|
||||
InlineKeyboardButton, InlineKeyboardMarkup, Message)
|
||||
from handlers.texts import ABOUT_VPN, WELCOME_TEXT
|
||||
from bot import bot
|
||||
from config import CHANNEL_URL, SUPPORT_CHAT_URL, APP_URL
|
||||
from database import add_connection, add_referral, check_connection_exists, get_trial
|
||||
from handlers.keys.trial_key import create_trial_key
|
||||
from handlers.texts import INSTRUCTIONS_TRIAL
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
BufferedInputFile,
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
Message,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import (
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_URL,
|
||||
CONNECT_ANDROID,
|
||||
CONNECT_IOS,
|
||||
DONATIONS_ENABLE,
|
||||
DOWNLOAD_ANDROID,
|
||||
DOWNLOAD_IOS,
|
||||
SUPPORT_CHAT_URL,
|
||||
)
|
||||
from database import add_connection, add_referral, check_connection_exists, get_trial, use_trial
|
||||
from handlers.keys.key_management import create_key
|
||||
from handlers.keys.trial_key import create_trial_key
|
||||
from handlers.texts import INSTRUCTIONS_TRIAL, WELCOME_TEXT, get_about_vpn
|
||||
from logger import logger
|
||||
|
||||
router = Router()
|
||||
|
||||
class FeedbackState(StatesGroup):
|
||||
waiting_for_feedback = State()
|
||||
|
||||
async def send_welcome_message(chat_id: int, trial_status: int):
|
||||
welcome_text = WELCOME_TEXT
|
||||
@router.callback_query(F.data == "start")
|
||||
async def handle_start_callback_query(
|
||||
callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool
|
||||
):
|
||||
await start_command(callback_query.message, state, session, admin)
|
||||
|
||||
image_path = os.path.join(os.path.dirname(__file__), 'pic.jpg')
|
||||
|
||||
if not os.path.isfile(image_path):
|
||||
await bot.send_message(chat_id, "Файл изображения не найден.")
|
||||
return
|
||||
@router.message(Command("start"))
|
||||
async def start_command(message: Message, state: FSMContext, session: Any, admin: bool):
|
||||
"""Обрабатывает команду /start, включает логику рефералов и подарков."""
|
||||
logger.info(f"Вызвана функция start_command для пользователя {message.chat.id}")
|
||||
|
||||
inline_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text='🔗 Подключить VPN', callback_data='connect_vpn')] if trial_status == 0 else [],
|
||||
[InlineKeyboardButton(text='👤 Мой профиль', callback_data='view_profile')],
|
||||
[InlineKeyboardButton(text='🔒 О VPN', callback_data='about_vpn')],
|
||||
[InlineKeyboardButton(text='📞 Поддержка', url=SUPPORT_CHAT_URL)],
|
||||
[InlineKeyboardButton(text='📢 Наш канал', url=CHANNEL_URL)],
|
||||
])
|
||||
await state.clear()
|
||||
|
||||
inline_keyboard.inline_keyboard = [row for row in inline_keyboard.inline_keyboard if row]
|
||||
if message.text:
|
||||
try:
|
||||
connection_exists = await check_connection_exists(message.chat.id)
|
||||
logger.info(f"Проверка существования подключения: {connection_exists}")
|
||||
|
||||
with open(image_path, 'rb') as image_from_buffer:
|
||||
await bot.send_photo(
|
||||
chat_id,
|
||||
BufferedInputFile(image_from_buffer.read(), filename="pic.jpg"),
|
||||
caption=welcome_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=inline_keyboard
|
||||
)
|
||||
|
||||
@router.message(Command('start'))
|
||||
async def start_command(message: Message):
|
||||
print(f"Received start command with text: {message.text}")
|
||||
if 'referral_' in message.text:
|
||||
referrer_tg_id = int(message.text.split('referral_')[1])
|
||||
print(f"Referral ID: {referrer_tg_id}")
|
||||
if not await check_connection_exists(message.from_user.id):
|
||||
await add_connection(message.from_user.id)
|
||||
await add_referral(message.from_user.id, referrer_tg_id)
|
||||
await message.answer("Вас пригласил друг, добро пожаловать!")
|
||||
else:
|
||||
await message.answer("Вы уже зарегистрированы в системе!")
|
||||
|
||||
trial_status = await get_trial(message.from_user.id)
|
||||
await send_welcome_message(message.chat.id, trial_status)
|
||||
if not connection_exists:
|
||||
await add_connection(tg_id=message.chat.id, session=session)
|
||||
logger.info(f"Пользователь {message.chat.id} успешно добавлен в базу данных.")
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'connect_vpn')
|
||||
async def handle_connect_vpn(callback_query: CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
user_id = callback_query.from_user.id
|
||||
if "gift_" in message.text:
|
||||
logger.info(f"Обнаружена ссылка на подарок: {message.text}")
|
||||
parts = message.text.split("gift_")[1].split("_")
|
||||
gift_id = parts[0]
|
||||
|
||||
trial_key_info = await create_trial_key(user_id)
|
||||
recipient_tg_id = message.chat.id
|
||||
|
||||
if 'error' in trial_key_info:
|
||||
await callback_query.message.answer(trial_key_info['error'])
|
||||
gift_info = await session.fetchrow(
|
||||
"SELECT * FROM gifts WHERE gift_id = $1 AND is_used = FALSE", gift_id
|
||||
)
|
||||
|
||||
if gift_info is None:
|
||||
logger.warning(f"Подарок с ID {gift_id} уже был использован или не существует.")
|
||||
await message.answer("Этот подарок уже был использован или не существует.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
if gift_info['sender_tg_id'] == recipient_tg_id:
|
||||
logger.warning(
|
||||
f"Пользователь {recipient_tg_id} попытался активировать подарок, который был отправлен им самим."
|
||||
)
|
||||
await message.answer("❌ Вы не можете получить подарок от самого себя.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
selected_months = gift_info['selected_months']
|
||||
expiry_time = gift_info['expiry_time']
|
||||
expiry_time_naive = expiry_time.replace(tzinfo=None)
|
||||
logger.info(f"Подарок с ID {gift_id} успешно найден для пользователя {recipient_tg_id}.")
|
||||
|
||||
await create_key(recipient_tg_id, expiry_time_naive, state, session, message)
|
||||
logger.info(f"Ключ создан для пользователя {recipient_tg_id} на срок {selected_months} месяцев.")
|
||||
|
||||
await session.execute(
|
||||
"UPDATE gifts SET is_used = TRUE, recipient_tg_id = $1 WHERE gift_id = $2",
|
||||
recipient_tg_id,
|
||||
gift_id,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 Ваш подарок на {selected_months} {'месяц' if selected_months == 1 else 'месяца' if selected_months in [2, 3, 4] else 'месяцев'} активирован!"
|
||||
)
|
||||
logger.info(f"Подарок на {selected_months} месяцев активирован для пользователя {recipient_tg_id}.")
|
||||
return
|
||||
|
||||
elif "referral_" in message.text:
|
||||
try:
|
||||
referrer_tg_id = int(message.text.split("referral_")[1])
|
||||
|
||||
if connection_exists:
|
||||
logger.info(
|
||||
f"Пользователь {message.chat.id} уже зарегистрирован и не может стать рефералом."
|
||||
)
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
if referrer_tg_id == message.chat.id:
|
||||
logger.warning(f"Пользователь {message.chat.id} попытался стать рефералом самого себя.")
|
||||
await message.answer("❌ Вы не можете быть рефералом самого себя.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
existing_referral = await session.fetchrow(
|
||||
"SELECT * FROM referrals WHERE referred_tg_id = $1", message.chat.id
|
||||
)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"Реферал с ID {message.chat.id} уже существует.")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
await add_referral(message.chat.id, referrer_tg_id, session)
|
||||
logger.info(f"Реферал {message.chat.id} использовал ссылку от пользователя {referrer_tg_id}")
|
||||
return await show_start_menu(message, admin, session)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки: {e}")
|
||||
return
|
||||
|
||||
else:
|
||||
logger.info(f"Пользователь {message.chat.id} зашел без реферальной ссылки или подарка.")
|
||||
|
||||
await show_start_menu(message, admin, session)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Ошибка при обработке сообщения пользователя {message.chat.id}: {e}")
|
||||
await message.answer("❌ Произошла ошибка. Пожалуйста, попробуйте снова.")
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
|
||||
|
||||
async def show_start_menu(message: Message, admin: bool, session: Any):
|
||||
"""Функция для отображения стандартного меню"""
|
||||
logger.info(f"Показываю главное меню для пользователя {message.chat.id}")
|
||||
trial_status = await get_trial(message.chat.id, session)
|
||||
image_path = os.path.join("img", "pic.jpg")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if trial_status == 0:
|
||||
builder.row(InlineKeyboardButton(text="🔗 Подключить VPN", callback_data="connect_vpn"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📞 Поддержка", url=SUPPORT_CHAT_URL),
|
||||
InlineKeyboardButton(text="📢 Канал", url=CHANNEL_URL),
|
||||
)
|
||||
|
||||
if admin:
|
||||
builder.row(InlineKeyboardButton(text="🔧 Администратор", callback_data="admin"))
|
||||
|
||||
builder.row(InlineKeyboardButton(text="🌐 О нашем VPN", callback_data="about_vpn"))
|
||||
|
||||
if os.path.isfile(image_path):
|
||||
with open(image_path, "rb") as image_from_buffer:
|
||||
await message.answer_photo(
|
||||
photo=BufferedInputFile(image_from_buffer.read(), filename="pic.jpg"),
|
||||
caption=WELCOME_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
text=WELCOME_TEXT,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "connect_vpn")
|
||||
async def handle_connect_vpn(callback_query: CallbackQuery, session: Any):
|
||||
user_id = callback_query.message.chat.id
|
||||
|
||||
trial_key_info = await create_trial_key(user_id, session)
|
||||
|
||||
if "error" in trial_key_info:
|
||||
await callback_query.message.answer(trial_key_info["error"])
|
||||
else:
|
||||
await use_trial(user_id, session)
|
||||
|
||||
key_message = (
|
||||
f"<b>Ваш ключ доступа:</b>\n<pre>{trial_key_info['key']}</pre>\n\n"
|
||||
f"<b>Инструкции:</b>\n{INSTRUCTIONS_TRIAL}"
|
||||
f"🔑 <b>Ваш персональный ключ доступа:</b>\n"
|
||||
f"<code>{trial_key_info['key']}</code>\n\n"
|
||||
f"📋 <b>Быстрая инструкция по подключению:</b>\n{INSTRUCTIONS_TRIAL}"
|
||||
)
|
||||
|
||||
button_profile = InlineKeyboardButton(text='👤 Мой профиль', callback_data='view_profile')
|
||||
|
||||
button_iphone = InlineKeyboardButton(
|
||||
text='🍏IPhone',
|
||||
url=f'{APP_URL}/?url=streisand://import/{trial_key_info["key"]}'
|
||||
)
|
||||
button_android = InlineKeyboardButton(
|
||||
text='🤖Android',
|
||||
url=f'{APP_URL}/?url=v2rayng://install-sub?url={trial_key_info["key"]}'
|
||||
)
|
||||
email = trial_key_info["email"]
|
||||
|
||||
inline_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[button_iphone, button_android],
|
||||
[button_profile]
|
||||
])
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
|
||||
InlineKeyboardButton(text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🍏 Подключить на iOS",
|
||||
url=f'{CONNECT_IOS}{trial_key_info["key"]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🤖 Подключить на Android",
|
||||
url=f'{CONNECT_ANDROID}{trial_key_info["key"]}',
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="💻 Компьютеры", callback_data=f"connect_pc|{email}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="📺 Андроид TV", callback_data=f"connect_tv|{email}"
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
|
||||
)
|
||||
|
||||
await callback_query.message.answer(
|
||||
key_message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=inline_keyboard
|
||||
key_message, reply_markup=builder.as_markup()
|
||||
)
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'about_vpn')
|
||||
@router.callback_query(F.data == "about_vpn")
|
||||
async def handle_about_vpn(callback_query: CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
info_message = ABOUT_VPN
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
button_back = InlineKeyboardButton(text='⬅️ Назад', callback_data='back_to_menu')
|
||||
inline_keyboard_back = InlineKeyboardMarkup(inline_keyboard=[[button_back]])
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📞 Техническая поддержка", url=SUPPORT_CHAT_URL),
|
||||
)
|
||||
if CHANNEL_EXISTS:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📢 Официальный канал", url=CHANNEL_URL),
|
||||
)
|
||||
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="start"))
|
||||
|
||||
await callback_query.message.answer(
|
||||
info_message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=inline_keyboard_back
|
||||
get_about_vpn("3.2.3-minor"), reply_markup=builder.as_markup()
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
@router.callback_query(lambda c: c.data == 'back_to_menu')
|
||||
async def handle_back_to_menu(callback_query: CallbackQuery):
|
||||
await callback_query.message.delete()
|
||||
trial_status = await get_trial(callback_query.from_user.id)
|
||||
await send_welcome_message(callback_query.from_user.id, trial_status)
|
||||
await callback_query.answer()
|
||||
|
||||
@@ -1,24 +1,93 @@
|
||||
import re
|
||||
import random
|
||||
from config import SERVERS
|
||||
import re
|
||||
|
||||
import asyncpg
|
||||
|
||||
from bot import bot
|
||||
from config import DATABASE_URL
|
||||
from database import get_servers_from_db
|
||||
from logger import logger
|
||||
|
||||
|
||||
def sanitize_key_name(key_name: str) -> str:
|
||||
return re.sub(r'[^a-z0-9@._-]', '', key_name.lower())
|
||||
"""
|
||||
Очищает название ключа, оставляя только допустимые символы.
|
||||
|
||||
def generate_random_email():
|
||||
"""Генерирует случайный набор символов."""
|
||||
random_string = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=6))
|
||||
return random_string
|
||||
Args:
|
||||
key_name (str): Исходное название ключа.
|
||||
|
||||
async def get_least_loaded_server(conn):
|
||||
"""Находит сервер с наименьшей загрузкой."""
|
||||
least_loaded_server_id = None
|
||||
min_load_percentage = float('inf')
|
||||
Returns:
|
||||
str: Очищенное название ключа в нижнем регистре.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9@._-]", "", key_name.lower())
|
||||
|
||||
for server_id, server in SERVERS.items():
|
||||
count = await conn.fetchval('SELECT COUNT(*) FROM keys WHERE server_id = $1', server_id)
|
||||
percent_full = (count / 60) * 100 if count <= 60 else 100
|
||||
if percent_full < min_load_percentage:
|
||||
min_load_percentage = percent_full
|
||||
least_loaded_server_id = server_id
|
||||
|
||||
return least_loaded_server_id
|
||||
def generate_random_email(length: int = 6) -> str:
|
||||
"""
|
||||
Генерирует случайный email с заданной длиной.
|
||||
|
||||
Args:
|
||||
length (int, optional): Длина случайной строки. По умолчанию 6.
|
||||
|
||||
Returns:
|
||||
str: Сгенерированная случайная строка.
|
||||
"""
|
||||
return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=length))
|
||||
|
||||
|
||||
async def get_least_loaded_cluster() -> str:
|
||||
"""
|
||||
Определяет кластер с наименьшей загрузкой.
|
||||
|
||||
Returns:
|
||||
str: Идентификатор наименее загруженного кластера.
|
||||
"""
|
||||
servers = await get_servers_from_db()
|
||||
|
||||
cluster_loads: dict[str, int] = {cluster_id: 0 for cluster_id in servers.keys()}
|
||||
|
||||
async with asyncpg.create_pool(DATABASE_URL) as pool:
|
||||
async with pool.acquire() as conn:
|
||||
keys = await conn.fetch("SELECT server_id FROM keys")
|
||||
for key in keys:
|
||||
cluster_id = key["server_id"]
|
||||
if cluster_id in cluster_loads:
|
||||
cluster_loads[cluster_id] += 1
|
||||
|
||||
logger.info(f"Cluster loads after database query: {cluster_loads}")
|
||||
|
||||
if not cluster_loads:
|
||||
logger.warning("No clusters found in database or configuration.")
|
||||
return "cluster1"
|
||||
|
||||
least_loaded_cluster = min(cluster_loads, key=lambda k: (cluster_loads[k], k))
|
||||
|
||||
logger.info(f"Least loaded cluster selected: {least_loaded_cluster}")
|
||||
|
||||
return least_loaded_cluster
|
||||
|
||||
|
||||
async def handle_error(
|
||||
tg_id: int, callback_query: object | None = None, message: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает ошибку, отправляя сообщение пользователю.
|
||||
|
||||
Args:
|
||||
tg_id (int): Идентификатор пользователя в Telegram.
|
||||
callback_query (Optional[object], optional): Объект запроса обратного вызова. По умолчанию None.
|
||||
message (str, optional): Текст сообщения об ошибке. По умолчанию пустая строка.
|
||||
"""
|
||||
try:
|
||||
if callback_query and hasattr(callback_query, "message"):
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=tg_id, message_id=callback_query.message.message_id
|
||||
)
|
||||
except Exception as delete_error:
|
||||
logger.warning(f"Не удалось удалить сообщение: {delete_error}")
|
||||
|
||||
await bot.send_message(tg_id, message, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке ошибки: {e}")
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
from loguru import logger
|
||||
|
||||
log_folder = "logs"
|
||||
|
||||
if not os.path.exists(log_folder):
|
||||
os.makedirs(log_folder)
|
||||
|
||||
logger.remove()
|
||||
|
||||
level_mapping = {
|
||||
50: "CRITICAL",
|
||||
40: "ERROR",
|
||||
30: "WARNING",
|
||||
20: "INFO",
|
||||
10: "DEBUG",
|
||||
0: "NOTSET",
|
||||
}
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
logger_opt = logger.opt(depth=6, exception=record.exc_info)
|
||||
message = record.getMessage()
|
||||
logger_opt.log(level_mapping.get(record.levelno, "INFO"), message)
|
||||
|
||||
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level="INFO",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level}</level> | <cyan>{module}:{function}:{line}</cyan> | <level>{message}</level>",
|
||||
colorize=True,
|
||||
)
|
||||
|
||||
log_file_path = os.path.join(log_folder, "logging.log")
|
||||
logger.add(
|
||||
log_file_path,
|
||||
level="DEBUG",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} | {message}",
|
||||
rotation=timedelta(minutes=60),
|
||||
retention=timedelta(days=3),
|
||||
)
|
||||
|
||||
logger = logger
|
||||
@@ -0,0 +1,27 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
class AdminMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
data["admin"] = self._check_admin_access(event)
|
||||
return await handler(event, data)
|
||||
|
||||
def _check_admin_access(self, event: TelegramObject) -> bool:
|
||||
try:
|
||||
admin_ids: int | list[int] = ADMIN_ID
|
||||
if isinstance(admin_ids, list):
|
||||
return event.from_user.id in admin_ids
|
||||
return event.from_user.id == admin_ids
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,23 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
|
||||
class DatabaseMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
data["session"] = conn
|
||||
return await handler(event, data)
|
||||
finally:
|
||||
await conn.close()
|
||||
@@ -0,0 +1,28 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||
|
||||
|
||||
class DeleteMessageMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
if isinstance(event, Message):
|
||||
if not event.text or not event.text.startswith("/start"):
|
||||
try:
|
||||
await event.bot.delete_message(
|
||||
event.chat.id, event.message_id - 1
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await event.delete()
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer()
|
||||
await event.message.delete()
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,43 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
user_info = self._extract_user_info(event)
|
||||
|
||||
logger.info(
|
||||
f"Активность пользователя - "
|
||||
f"ID пользователя: {user_info['user_id']}, "
|
||||
f"Имя пользователя: {user_info['username']}, "
|
||||
f"Действие: {user_info['action']}"
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
def _extract_user_info(self, event: TelegramObject) -> dict[str, str | None]:
|
||||
user_id = None
|
||||
username = None
|
||||
action = None
|
||||
|
||||
if isinstance(event, Message):
|
||||
user = event.from_user
|
||||
user_id = user.id
|
||||
username = user.username
|
||||
action = f"Сообщение: {event.text}"
|
||||
elif isinstance(event, CallbackQuery):
|
||||
user = event.from_user
|
||||
user_id = user.id
|
||||
username = user.username
|
||||
action = f"Обратный вызов: {event.data}"
|
||||
|
||||
return {"user_id": user_id, "username": username, "action": action}
|
||||
@@ -0,0 +1,29 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, User
|
||||
|
||||
from database import upsert_user
|
||||
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
if user := data.get("event_from_user"):
|
||||
await self._process_user(user)
|
||||
return await handler(event, data)
|
||||
|
||||
async def _process_user(self, user: User) -> None:
|
||||
await upsert_user(
|
||||
tg_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
language_code=user.language_code,
|
||||
is_bot=user.is_bot,
|
||||
)
|
||||
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,17 @@
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "ANN", "ASYNC", "S", "BLE", "FBT", "B", "A", "C4", "DTZ", "T10", "ISC", "ICN", "G", "PIE"]
|
||||
ignore = ["ANN101", "ANN102", "S101",'ANN201','ANN001','BLE001']
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.darker]
|
||||
src = ["."]
|
||||
revision = "HEAD"
|
||||
diff = false
|
||||
check = false
|
||||
@@ -7,7 +7,7 @@ annotated-types==0.7.0
|
||||
async-timeout==4.0.3
|
||||
asyncpg==0.30.0
|
||||
attrs==24.2.0
|
||||
certifi==2024.8.30
|
||||
certifi
|
||||
charset-normalizer==3.4.0
|
||||
Deprecated==1.2.14
|
||||
distro==1.9.0
|
||||
@@ -17,11 +17,18 @@ magic-filter==1.0.12
|
||||
multidict==6.1.0
|
||||
netaddr==1.3.0
|
||||
propcache==0.2.0
|
||||
pydantic==2.9.2
|
||||
pydantic_core==2.23.4
|
||||
pydantic
|
||||
pydantic_core
|
||||
requests==2.32.3
|
||||
typing_extensions==4.12.2
|
||||
urllib3==2.2.3
|
||||
wrapt==1.16.0
|
||||
yarl==1.15.5
|
||||
yookassa==3.3.0
|
||||
loguru
|
||||
aiocryptopay
|
||||
py3xui
|
||||
sqlalchemy
|
||||
robokassa
|
||||
ping3
|
||||
ruff
|
||||
@@ -0,0 +1,200 @@
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from ping3 import ping
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
from database import get_servers_from_db
|
||||
from logger import logger
|
||||
|
||||
try:
|
||||
from config import CLUSTERS
|
||||
except ImportError:
|
||||
CLUSTERS = None
|
||||
logger.warning(
|
||||
"Переменная CLUSTERS не найдена в конфигурации. Добавьте сервера через админ-панель!"
|
||||
)
|
||||
|
||||
|
||||
async def sync_servers_with_db():
|
||||
"""
|
||||
Синхронизирует сервера из конфигурации CLUSTERS с базой данных.
|
||||
Если CLUSTERS не найден, синхронизация не будет выполнена.
|
||||
"""
|
||||
if CLUSTERS is None:
|
||||
logger.info(
|
||||
"Конфигурация CLUSTERS не найдена. Синхронизация не будет выполнена."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
logger.info("Подключение к базе данных для синхронизации серверов успешно.")
|
||||
|
||||
for cluster_name, servers in CLUSTERS.items():
|
||||
for server_key, server_info in servers.items():
|
||||
exists = await conn.fetchval(
|
||||
"""
|
||||
SELECT 1 FROM servers
|
||||
WHERE cluster_name = $1 AND server_name = $2
|
||||
""",
|
||||
cluster_name,
|
||||
server_info["name"],
|
||||
)
|
||||
|
||||
if not exists:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
cluster_name,
|
||||
server_info["name"],
|
||||
server_info["API_URL"],
|
||||
server_info["SUBSCRIPTION"],
|
||||
server_info["INBOUND_ID"],
|
||||
)
|
||||
logger.info(
|
||||
f"Сервер {server_info['name']} из кластера {cluster_name} добавлен в базу данных."
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Сервер {server_info['name']} из кластера {cluster_name} уже существует."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при синхронизации серверов: {e}")
|
||||
finally:
|
||||
if "conn" in locals():
|
||||
await conn.close()
|
||||
|
||||
|
||||
last_ping_times = {}
|
||||
last_notification_times = {}
|
||||
|
||||
|
||||
async def ping_server(server_ip: str) -> bool:
|
||||
"""
|
||||
Функция пинга сервера.
|
||||
Возвращает True, если сервер доступен, иначе False.
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Пингуем сервер {server_ip}...")
|
||||
response = ping(server_ip, timeout=3)
|
||||
if response is False:
|
||||
logger.warning(f"Сервер {server_ip} не отвечает.")
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при пинге сервера {server_ip}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def notify_admin(server_name: str):
|
||||
"""
|
||||
Отправляет уведомление всем администраторам о недоступности сервера.
|
||||
Уведомления отправляются не чаще чем раз в 3 минуты.
|
||||
"""
|
||||
try:
|
||||
current_time = datetime.now()
|
||||
last_notification_time = last_notification_times.get(server_name)
|
||||
|
||||
if last_notification_time and current_time - last_notification_time < timedelta(
|
||||
minutes=3
|
||||
):
|
||||
logger.info(
|
||||
f"Не отправляем уведомление для сервера {server_name}, так как прошло менее 3 минут."
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Отправка уведомлений администратору о недоступности сервера {server_name}..."
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="Управление сервером", callback_data=f"manage_server|{server_name}"
|
||||
)
|
||||
)
|
||||
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_message(
|
||||
admin_id,
|
||||
(
|
||||
f"❌ <b>Сервер '{server_name}'</b> не отвечает более 3 минут.\n\n"
|
||||
"Проверьте соединение к серверу, подключение к панели или удалите его из таблицы серверов в боте, "
|
||||
"чтобы не выдать подписку к неработающему серверу."
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
logger.info(
|
||||
f"Уведомление отправлено администратору с ID {admin_id} о сервере {server_name}."
|
||||
)
|
||||
|
||||
last_notification_times[server_name] = current_time
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления администраторам: {e}")
|
||||
|
||||
|
||||
async def check_servers():
|
||||
"""
|
||||
Периодическая проверка серверов с учетом извлечения хоста из `api_url`.
|
||||
"""
|
||||
while True:
|
||||
servers = await get_servers_from_db()
|
||||
current_time = datetime.now()
|
||||
|
||||
logger.info(f"Начинаю проверку серверов: {current_time}")
|
||||
|
||||
for cluster_name, cluster_servers in servers.items():
|
||||
logger.debug(f"Проверка кластеров: {cluster_name}")
|
||||
for server in cluster_servers:
|
||||
original_api_url = server["api_url"]
|
||||
server_name = server["server_name"]
|
||||
|
||||
server_host = extract_host(original_api_url)
|
||||
logger.debug(
|
||||
f"Проверка доступности сервера '{server_name}' с хостом {server_host}"
|
||||
)
|
||||
|
||||
is_online = await ping_server(server_host)
|
||||
|
||||
if is_online:
|
||||
last_ping_times[server_name] = current_time
|
||||
else:
|
||||
last_ping_time = last_ping_times.get(server_name)
|
||||
if last_ping_time and current_time - last_ping_time > timedelta(
|
||||
minutes=3
|
||||
):
|
||||
logger.warning(
|
||||
f"Сервер {server_name} не отвечает более 3 минут. Отправляю уведомление."
|
||||
)
|
||||
await notify_admin(server_name)
|
||||
elif not last_ping_time:
|
||||
last_ping_times[server_name] = current_time
|
||||
logger.info(
|
||||
f"Сервер {server_name} не отвечал ранее, но теперь зарегистрирован."
|
||||
)
|
||||
|
||||
logger.info("Завершена проверка всех серверов.")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def extract_host(api_url: str) -> str:
|
||||
"""
|
||||
Извлекает только хост из `api_url` (без путей, портов и параметров).
|
||||
"""
|
||||
match = re.match(r"(https?://)?([^:/]+)", api_url)
|
||||
if match:
|
||||
host = match.group(2)
|
||||
logger.debug(f"Извлечён хост: {host} из URL: {api_url}")
|
||||
return host
|
||||
logger.error(f"Не удалось извлечь хост из URL: {api_url}")
|
||||
return api_url
|
||||