├── .github
├── hooks
│ └── pre-commit
└── images
│ ├── preview.gif
│ └── preview_2.gif
├── .gitignore
├── .python-version
├── LICENSE
├── Makefile
├── README.md
├── assets
└── schema.sql
├── backup.py
├── bot.py
├── cli_launcher.py
├── database.py
├── filters
├── admin.py
└── private.py
├── handlers
├── __init__.py
├── admin
│ ├── __init__.py
│ ├── ads
│ │ ├── __init__.py
│ │ ├── ads_handler.py
│ │ └── keyboard.py
│ ├── backups
│ │ ├── __init__.py
│ │ └── backups_handler.py
│ ├── bans
│ │ ├── __init__.py
│ │ ├── bans_handler.py
│ │ └── keyboard.py
│ ├── clusters
│ │ ├── __init__.py
│ │ ├── clusters_handler.py
│ │ └── keyboard.py
│ ├── coupons
│ │ ├── __init__.py
│ │ ├── coupons_handler.py
│ │ └── keyboard.py
│ ├── management
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── management_handler.py
│ ├── panel
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── panel_handler.py
│ ├── restart
│ │ ├── __init__.py
│ │ └── restart_handler.py
│ ├── sender
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── sender_handler.py
│ ├── servers
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── servers_handler.py
│ ├── stats
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── stats_handler.py
│ └── users
│ │ ├── __init__.py
│ │ ├── keyboard.py
│ │ └── users_handler.py
├── buttons.py
├── captcha.py
├── coupons.py
├── donate.py
├── instructions
│ ├── __init__.py
│ └── instructions.py
├── keys
│ ├── __init__.py
│ ├── key_connect.py
│ ├── key_freeze.py
│ ├── key_mode
│ │ ├── __init__.py
│ │ ├── key_cluster_mode.py
│ │ ├── key_country_mode.py
│ │ └── key_create.py
│ ├── key_renew.py
│ ├── key_utils.py
│ ├── key_view.py
│ ├── keys.py
│ └── subscriptions.py
├── notifications
│ ├── __init__.py
│ ├── general_notifications.py
│ ├── notify_kb.py
│ ├── notify_utils.py
│ └── special_notifications.py
├── pay.py
├── payments
│ ├── __init__.py
│ ├── cryprobot_pay.cpython-312-x86_64-linux-gnu.so
│ ├── gift.cpython-312-x86_64-linux-gnu.so
│ ├── robokassa_pay.py
│ ├── stars_pay.cpython-312-x86_64-linux-gnu.so
│ ├── utils.cpython-312-x86_64-linux-gnu.so
│ ├── yookassa_pay.cpython-312-x86_64-linux-gnu.so
│ └── yoomoney_pay.cpython-312-x86_64-linux-gnu.so
├── profile.py
├── refferal.py
├── start.py
└── utils.py
├── img
├── gifts.jpg
├── instructions.jpg
├── notify_10h.jpg
├── notify_24h.jpg
├── notify_expired.jpg
├── pic.jpg
├── pic_invite.jpg
├── pic_keys.jpg
├── pic_view.jpg
├── profile.jpg
└── tariffs.jpg
├── logger.py
├── main.py
├── middlewares
├── __init__.py
├── admin.py
├── loggings.py
├── maintenance.py
├── session.py
├── throttling.py
└── user.py
├── panels
├── remnawave.cpython-312-x86_64-linux-gnu.so
└── three_xui.py
├── pyproject.toml
├── requirements.txt
├── servers.py
├── utils
├── __init__.py
└── csv_export.py
├── uv.lock
└── web
└── __init__.py
/.github/hooks/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Сохраняем текущие изменения
4 | echo "Сохранение текущих изменений..."
5 | git stash -q --keep-index
6 |
7 | # Запускаем форматирование кода
8 | echo "Запуск Ruff format..."
9 | ruff format . --config pyproject.toml --exclude main.py,handlers/payments
10 |
11 | # Запускаем проверку и исправление кода
12 | echo "Запуск Ruff check с автоисправлением..."
13 | ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
14 |
15 | # Добавляем изменения, внесенные форматированием
16 | git add -u
17 |
18 | # Восстанавливаем сохраненные изменения
19 | echo "Восстановление сохраненных изменений..."
20 | git stash pop -q
21 |
22 | # Выход с кодом 0 (успешно)
23 | exit 0
--------------------------------------------------------------------------------
/.github/images/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/.github/images/preview.gif
--------------------------------------------------------------------------------
/.github/images/preview_2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/.github/images/preview_2.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # Distribution / packaging
7 | dist/
8 | build/
9 | *.egg-info/
10 |
11 | # Virtual environments
12 | venv/
13 | env/
14 | .env/
15 | .venv/
16 |
17 | # IDE specific files
18 | .idea/
19 | .vscode/
20 | *.sublime-project
21 | *.sublime-workspace
22 |
23 | # Logs and databases
24 | *.log
25 | *.sqlite3
26 | *.db
27 |
28 | # Sensitive configuration files
29 | config.py
30 | config.ini
31 | .env
32 |
33 | # Backup files
34 | *.bak
35 | *.swp
36 | *~
37 |
38 | # Specific project files
39 | vpn_users.db
40 | database.db
41 | bot_old.py
42 | bot_old_2.py
43 | backup_pg.sh
44 | docker-compose.yml
45 | config copy.py
46 | handlers/texts.py
47 |
48 | # Miscellaneous
49 | .DS_Store
50 | Thumbs.db
51 |
52 | nginx.conf
53 | scripts
54 | models.py
55 | Dockerfile
56 | .csv
57 | /logs
58 | setup.py
59 | .ruff_cache
60 | .github/workflows/
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.12
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | formatting:
2 | @echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
3 |
4 | @echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
5 |
6 | lint:
7 | @echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚀 SoloBot
2 |
3 | ## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
4 |
5 |
6 |
7 | # Описание
8 |
9 | ## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
10 |
11 | ## ⚙️ Основные возможности SoloBot
12 |
13 | | 📌 Раздел | 💡 Возможности |
14 | |----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
15 | | **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui)
• Работа с панелями [Remnawave](https://github.com/remnawave/panel)
• Работа в режиме совместимости (различные панели одновременно) |
16 | | **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки
• Пробный период на заданный период
• Продление ключей по тарифному плану
• Поддержка различных клиентов (Happ, Hiddify, v2RayTun)
• Кастомизируемые заголовки профилей |
17 | | **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку
• Продление и удаление ключей, начисление дней, отключение клиента
• Смена локации между серверами
• Поддержка нескольких устройств |
18 | | **Реферальная программа** | • Уникальные ссылки для приглашений
• Инлайн-режим и обычные сообщения
• Награда: процент или фиксированная сумма за пополнение |
19 | | **UTM-аналитика** | • Отслеживание рекламных переходов
• Привязка по рефералам, купонам, пробникам
• Анализ конверсий: регистрации, покупки, триалы
• Удалённый просмотр и контроль через админку |
20 | | **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые)
• YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine)
• Robokassa (ИП)
• Cryptobot [@izzzzzi](https://github.com/izzzzzi)
• Telegram Stars |
21 | | **Безопасность и стабильность** | • Периодические бэкапы
• Смена домена в случае переезда
• Проверка доступности серверов
• Уведомление о недоступности сервера и его аптайм
|
22 | | **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент)
• Напоминания о неиспользованном трафике |
23 | | **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве)
• Выдача в разных режимах (по одной локации или в формате подписки)
• Автопроверка доступности
• Балансировка нагрузки при выдаче ключей
• Синхронизация клиентов между серверами
• Ограничение максимального количества ключей на сервер
• Возможность включения/отключения отдельных серверов |
24 | | **Админ-панель** | • Поиск по TG ID / username / ключу / email
• Управление балансом, подписками, заморозками
• Перезагрузка, бан-лист
• Создание купонов, UTM, статистика |
25 | | **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы
|
26 |
27 | ## 🛠 Обновления
28 |
29 | ### SoloBot регулярно получает новые функции и улучшения.
30 |
31 | #### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
32 |
33 | # Наш сайт и полная версия
34 |
35 | ### Переходи на наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
36 |
37 | #### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
38 |
39 | 
40 |
41 | Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
42 |
43 | ## Отзывы пользователей:
44 |
45 | #### SoloBot уже помог сотням пользователей в нашем сообществе:
46 |
47 | 
48 |
49 | **Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
50 |
51 | Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
52 | и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
53 |
54 | ## 🚨 Права на использование
55 |
56 | | ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
57 | |----------------------------------|----------------------------------------------------------------------|
58 | | ⛔ **Монетизация кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
59 | | ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
60 |
61 | > **Нарушение приведёт к блокировке доступа и может повлечь ответственность согласно законодательству РФ.**
62 |
63 | ## Участники проекта
64 |
65 | Благодарим всех, кто помогает развивать SoloBot! 💖
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/assets/schema.sql:
--------------------------------------------------------------------------------
1 |
2 | CREATE TABLE IF NOT EXISTS users
3 | (
4 | tg_id BIGINT PRIMARY KEY NOT NULL,
5 | username TEXT,
6 | first_name TEXT,
7 | last_name TEXT,
8 | language_code TEXT,
9 | is_bot BOOLEAN DEFAULT FALSE,
10 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
11 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
12 | balance REAL NOT NULL DEFAULT 0.0,
13 | trial INTEGER NOT NULL DEFAULT 0
14 | );
15 |
16 | DO $$
17 | BEGIN
18 | IF NOT EXISTS (
19 | SELECT 1 FROM information_schema.columns
20 | WHERE table_name = 'users' AND column_name = 'balance'
21 | ) THEN
22 | ALTER TABLE users ADD COLUMN balance REAL NOT NULL DEFAULT 0.0;
23 | END IF;
24 |
25 | IF NOT EXISTS (
26 | SELECT 1 FROM information_schema.columns
27 | WHERE table_name = 'users' AND column_name = 'trial'
28 | ) THEN
29 | ALTER TABLE users ADD COLUMN trial INTEGER NOT NULL DEFAULT 0;
30 | END IF;
31 | END$$;
32 |
33 |
34 | CREATE TABLE IF NOT EXISTS payments
35 | (
36 | id SERIAL PRIMARY KEY,
37 | tg_id BIGINT NOT NULL,
38 | amount REAL NOT NULL,
39 | payment_system TEXT NOT NULL,
40 | status TEXT DEFAULT 'success',
41 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
42 | FOREIGN KEY (tg_id) REFERENCES users (tg_id)
43 | );
44 |
45 | CREATE TABLE IF NOT EXISTS keys
46 | (
47 | tg_id BIGINT NOT NULL,
48 | client_id TEXT NOT NULL,
49 | email TEXT NOT NULL,
50 | created_at BIGINT NOT NULL,
51 | expiry_time BIGINT NOT NULL,
52 | key TEXT NOT NULL,
53 | server_id TEXT NOT NULL DEFAULT 'cluster1',
54 | notified BOOLEAN NOT NULL DEFAULT FALSE,
55 | notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
56 | PRIMARY KEY (tg_id, client_id)
57 | );
58 |
59 | DO $$
60 | BEGIN
61 | IF EXISTS (
62 | SELECT 1
63 | FROM information_schema.columns
64 | WHERE table_name = 'keys' AND column_name = 'key' AND is_nullable = 'NO'
65 | ) THEN
66 | ALTER TABLE keys ALTER COLUMN key DROP NOT NULL;
67 | END IF;
68 |
69 | IF NOT EXISTS (
70 | SELECT 1 FROM information_schema.columns
71 | WHERE table_name = 'keys' AND column_name = 'remnawave_link'
72 | ) THEN
73 | ALTER TABLE keys ADD COLUMN remnawave_link TEXT;
74 | END IF;
75 |
76 | IF NOT EXISTS (
77 | SELECT 1 FROM information_schema.columns
78 | WHERE table_name = 'keys' AND column_name = 'is_frozen'
79 | ) THEN
80 | ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
81 | END IF;
82 |
83 | IF NOT EXISTS (
84 | SELECT 1 FROM information_schema.columns
85 | WHERE table_name = 'keys' AND column_name = 'alias'
86 | ) THEN
87 | ALTER TABLE keys ADD COLUMN alias TEXT;
88 | END IF;
89 | END$$;
90 |
91 | CREATE TABLE IF NOT EXISTS referrals
92 | (
93 | referred_tg_id BIGINT PRIMARY KEY NOT NULL,
94 | referrer_tg_id BIGINT NOT NULL,
95 | reward_issued BOOLEAN DEFAULT FALSE
96 | );
97 |
98 | CREATE TABLE IF NOT EXISTS coupons
99 | (
100 | id SERIAL PRIMARY KEY,
101 | code TEXT UNIQUE NOT NULL,
102 | amount INTEGER NOT NULL,
103 | days INTEGER CHECK (days > 0 OR days IS NULL),
104 | usage_limit INTEGER NOT NULL DEFAULT 1,
105 | usage_count INTEGER NOT NULL DEFAULT 0,
106 | is_used BOOLEAN NOT NULL DEFAULT FALSE
107 | );
108 |
109 | DO $$
110 | BEGIN
111 | IF NOT EXISTS (
112 | SELECT 1 FROM information_schema.columns
113 | WHERE table_name = 'coupons' AND column_name = 'days'
114 | ) THEN
115 | ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
116 | END IF;
117 | END$$;
118 |
119 | CREATE TABLE IF NOT EXISTS coupon_usages
120 | (
121 | coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
122 | user_id BIGINT NOT NULL,
123 | used_at TIMESTAMP NOT NULL DEFAULT NOW(),
124 | PRIMARY KEY (coupon_id, user_id)
125 | );
126 |
127 | CREATE TABLE IF NOT EXISTS notifications
128 | (
129 | tg_id BIGINT NOT NULL,
130 | last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
131 | notification_type TEXT NOT NULL,
132 | PRIMARY KEY (tg_id, notification_type)
133 | );
134 |
135 | CREATE TABLE IF NOT EXISTS servers
136 | (
137 | id SERIAL PRIMARY KEY,
138 | cluster_name TEXT NOT NULL,
139 | server_name TEXT NOT NULL,
140 | api_url TEXT NOT NULL,
141 | subscription_url TEXT,
142 | inbound_id TEXT NOT NULL,
143 | panel_type TEXT NOT NULL DEFAULT '3x-ui',
144 | enabled BOOLEAN NOT NULL DEFAULT TRUE,
145 | max_keys INTEGER,
146 | UNIQUE (cluster_name, server_name)
147 | );
148 |
149 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS panel_type TEXT NOT NULL DEFAULT '3x-ui';
150 | ALTER TABLE servers
151 | ALTER COLUMN subscription_url DROP NOT NULL;
152 | ALTER TABLE servers
153 | ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE;
154 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS max_keys INTEGER;
155 |
156 | CREATE TABLE IF NOT EXISTS gifts
157 | (
158 | gift_id TEXT PRIMARY KEY NOT NULL,
159 | sender_tg_id BIGINT NOT NULL,
160 | selected_months INTEGER NOT NULL,
161 | expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
162 | gift_link TEXT NOT NULL,
163 | created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
164 | is_used BOOLEAN NOT NULL DEFAULT FALSE,
165 | recipient_tg_id BIGINT,
166 | CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
167 | CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
168 | );
169 |
170 | CREATE TABLE IF NOT EXISTS temporary_data (
171 | tg_id BIGINT PRIMARY KEY NOT NULL,
172 | state TEXT NOT NULL,
173 | data JSONB NOT NULL,
174 | updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
175 | );
176 |
177 | CREATE TABLE IF NOT EXISTS blocked_users (
178 | tg_id BIGINT PRIMARY KEY,
179 | blocked_at TIMESTAMP DEFAULT NOW()
180 | );
181 |
182 |
183 | CREATE TABLE IF NOT EXISTS tracking_sources (
184 | id SERIAL PRIMARY KEY,
185 | code TEXT UNIQUE NOT NULL,
186 | type TEXT NOT NULL,
187 | name TEXT NOT NULL,
188 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
189 | created_by BIGINT,
190 | is_active BOOLEAN DEFAULT TRUE
191 | );
192 |
193 |
194 | ALTER TABLE users ADD COLUMN IF NOT EXISTS source_code TEXT REFERENCES tracking_sources (code);
195 |
196 |
197 | DO $$
198 | BEGIN
199 | IF EXISTS (
200 | SELECT FROM information_schema.tables
201 | WHERE table_schema = 'public' AND table_name = 'connections'
202 | ) THEN
203 | EXECUTE $upd$
204 | UPDATE users
205 | SET balance = c.balance,
206 | trial = c.trial
207 | FROM connections c
208 | WHERE users.tg_id = c.tg_id;
209 | $upd$;
210 |
211 | EXECUTE 'DROP TABLE connections';
212 | END IF;
213 | END$$;
--------------------------------------------------------------------------------
/backup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 |
4 | from datetime import datetime, timedelta
5 | from pathlib import Path
6 |
7 | import aiofiles
8 |
9 | from aiogram.types import BufferedInputFile
10 |
11 | from bot import bot
12 | from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
13 | from logger import logger
14 |
15 |
16 | async def backup_database() -> Exception | None:
17 | """
18 | Создает резервную копию базы данных и отправляет ее администраторам.
19 |
20 | Returns:
21 | Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
22 | """
23 | backup_file_path, exception = _create_database_backup()
24 |
25 | if exception:
26 | logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
27 | return exception
28 |
29 | try:
30 | await _send_backup_to_admins(backup_file_path)
31 | exception = _cleanup_old_backups()
32 |
33 | if exception:
34 | logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
35 | return exception
36 |
37 | return None
38 | except Exception as e:
39 | logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
40 | return e
41 |
42 |
43 | def _create_database_backup() -> tuple[str | None, Exception | None]:
44 | """
45 | Создает резервную копию базы данных PostgreSQL.
46 |
47 | Returns:
48 | Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
49 | """
50 | date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
51 |
52 | backup_dir = Path(BACK_DIR)
53 | backup_dir.mkdir(parents=True, exist_ok=True)
54 |
55 | filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
56 |
57 | try:
58 | os.environ["PGPASSWORD"] = DB_PASSWORD
59 |
60 | subprocess.run(
61 | [
62 | "pg_dump",
63 | "-U",
64 | DB_USER,
65 | "-h",
66 | PG_HOST,
67 | "-p",
68 | PG_PORT,
69 | "-F",
70 | "c",
71 | "-f",
72 | str(filename),
73 | DB_NAME,
74 | ],
75 | check=True,
76 | capture_output=True,
77 | text=True,
78 | )
79 | logger.info(f"Бэкап базы данных создан: {filename}")
80 | return str(filename), None
81 | except subprocess.CalledProcessError as e:
82 | logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
83 | return None, e
84 | except Exception as e:
85 | logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
86 | return None, e
87 | finally:
88 | if "PGPASSWORD" in os.environ:
89 | del os.environ["PGPASSWORD"]
90 |
91 |
92 | def _cleanup_old_backups() -> Exception | None:
93 | """
94 | Удаляет бэкапы старше 3 дней.
95 |
96 | Returns:
97 | Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
98 | """
99 | try:
100 | backup_dir = Path(BACK_DIR)
101 | if not backup_dir.exists():
102 | return None
103 |
104 | cutoff_date = datetime.now() - timedelta(days=3)
105 |
106 | for backup_file in backup_dir.glob("*.sql"):
107 | if backup_file.is_file():
108 | file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
109 | if file_mtime < cutoff_date:
110 | backup_file.unlink()
111 | logger.info(f"Удален старый бэкап: {backup_file}")
112 |
113 | logger.info("Очистка старых бэкапов завершена")
114 | return None
115 | except Exception as e:
116 | logger.error(f"Ошибка при удалении старых бэкапов: {e}")
117 | return e
118 |
119 |
120 | async def create_backup_and_send_to_admins(client) -> None:
121 | """
122 | Создает бэкап и отправляет администраторам через переданный клиент.
123 |
124 | Args:
125 | client: Клиент для работы с базой данных
126 | """
127 | await client.login()
128 | await client.database.export()
129 |
130 |
131 | async def _send_backup_to_admins(backup_file_path: str) -> None:
132 | """
133 | Отправляет файл бэкапа всем администраторам через Telegram.
134 |
135 | Args:
136 | backup_file_path: Путь к файлу бэкапа
137 |
138 | Raises:
139 | Exception: При ошибке отправки файла
140 | """
141 | if not backup_file_path or not os.path.exists(backup_file_path):
142 | raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
143 |
144 | try:
145 | async with aiofiles.open(backup_file_path, "rb") as backup_file:
146 | backup_data = await backup_file.read()
147 | filename = os.path.basename(backup_file_path)
148 | backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
149 |
150 | for admin_id in ADMIN_ID:
151 | try:
152 | await bot.send_document(chat_id=admin_id, document=backup_input_file)
153 | logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
154 | except Exception as e:
155 | logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
156 | except Exception as e:
157 | logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
158 | raise
159 |
--------------------------------------------------------------------------------
/bot.py:
--------------------------------------------------------------------------------
1 | import traceback
2 |
3 | from aiogram import Bot, Dispatcher
4 | from aiogram.client.default import DefaultBotProperties
5 | from aiogram.enums import ParseMode
6 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
7 | from aiogram.filters import ExceptionTypeFilter
8 | from aiogram.fsm.storage.memory import MemoryStorage
9 | from aiogram.types import BufferedInputFile, ErrorEvent
10 | from aiogram.utils.markdown import hbold
11 |
12 | from config import ADMIN_ID, API_TOKEN
13 | from filters.private import IsPrivateFilter
14 | from logger import logger
15 | from middlewares import register_middleware
16 |
17 |
18 | bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
19 | storage = MemoryStorage()
20 | dp = Dispatcher(bot=bot, storage=storage)
21 |
22 | version = "4.2.1"
23 |
24 |
25 | register_middleware(dp)
26 |
27 | dp.message.filter(IsPrivateFilter())
28 | dp.callback_query.filter(IsPrivateFilter())
29 |
30 |
31 | @dp.errors(ExceptionTypeFilter(Exception))
32 | async def errors_handler(
33 | event: ErrorEvent,
34 | bot: Bot,
35 | ) -> bool:
36 | if isinstance(event.exception, TelegramForbiddenError):
37 | logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
38 | return True
39 |
40 | if isinstance(event.exception, TelegramBadRequest):
41 | error_message = str(event.exception)
42 |
43 | if (
44 | "query is too old and response timeout expired or query ID is invalid" in error_message
45 | or "message can't be deleted for everyone" in error_message
46 | or "message to delete not found" in error_message
47 | ):
48 | logger.warning("Отправляем стартовое меню.")
49 |
50 | try:
51 | from handlers.start import handle_start_callback_query, start_command
52 |
53 | if event.update.message:
54 | await start_command(
55 | event.update.message, state=dp.storage, session=None, admin=False, captcha=False
56 | )
57 | elif event.update.callback_query:
58 | await handle_start_callback_query(
59 | event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
60 | )
61 | except Exception as e:
62 | logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
63 |
64 | return True
65 | logger.exception(f"Update: {event.update}\nException: {event.exception}")
66 | if not ADMIN_ID:
67 | return True
68 | try:
69 | for admin_id in ADMIN_ID:
70 | await bot.send_document(
71 | chat_id=admin_id,
72 | document=BufferedInputFile(
73 | traceback.format_exc().encode(),
74 | filename=f"error_{event.update.update_id}.txt",
75 | ),
76 | caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
77 | )
78 | try:
79 | from handlers.start import handle_start_callback_query, start_command
80 |
81 | if event.update.message:
82 | await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
83 | elif event.update.callback_query:
84 | await handle_start_callback_query(
85 | event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
86 | )
87 | except Exception as e:
88 | logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
89 | except TelegramBadRequest as exception:
90 | logger.warning(f"Failed to send error details: {exception}")
91 | except Exception as exception:
92 | logger.error(f"Unexpected error in error handler: {exception}")
93 | return True
94 |
--------------------------------------------------------------------------------
/filters/admin.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters import BaseFilter
2 | from aiogram.types import Message
3 |
4 | from config import ADMIN_ID
5 |
6 |
7 | class IsAdminFilter(BaseFilter):
8 | async def __call__(self, message: Message) -> bool:
9 | try:
10 | return message.from_user.id in ADMIN_ID
11 | except Exception:
12 | return False
13 |
--------------------------------------------------------------------------------
/filters/private.py:
--------------------------------------------------------------------------------
1 | from aiogram.enums import ChatType
2 | from aiogram.filters import BaseFilter
3 | from aiogram.types import Chat, TelegramObject
4 |
5 |
6 | class IsPrivateFilter(BaseFilter):
7 | async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
8 | return event_chat.type == ChatType.PRIVATE
9 |
--------------------------------------------------------------------------------
/handlers/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .admin import router as admin_router
6 | from .captcha import router as captcha_router
7 | from .coupons import router as coupons_router
8 | from .donate import router as donate_router
9 | from .instructions import router as instructions_router
10 | from .keys import router as keys_router
11 | from .notifications import router as notifications_router
12 | from .pay import router as pay_router
13 | from .payments import router as payments_router
14 | from .profile import router as profile_router
15 | from .refferal import router as refferal_router
16 | from .start import router as start_router
17 |
18 |
19 | router = Router(name="handlers_main_router")
20 |
21 | router.include_routers(
22 | start_router,
23 | captcha_router,
24 | profile_router,
25 | pay_router,
26 | donate_router,
27 | coupons_router,
28 | notifications_router,
29 | payments_router,
30 | keys_router,
31 | instructions_router,
32 | admin_router,
33 | refferal_router,
34 | )
35 |
--------------------------------------------------------------------------------
/handlers/admin/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .ads import router as ads_router
6 | from .backups import router as backups_router
7 | from .bans import router as bans_router
8 | from .clusters import router as clusters_router
9 | from .coupons import router as coupons_router
10 | from .management import router as management_router
11 | from .panel import router as panel_router
12 | from .restart import router as restart_router
13 | from .sender import router as sender_router
14 | from .servers import router as servers_router
15 | from .stats import router as stats_router
16 | from .users import router as users_router
17 |
18 |
19 | router = Router(name="admins_main_router")
20 |
21 | router.include_routers(
22 | panel_router,
23 | management_router,
24 | servers_router,
25 | clusters_router,
26 | users_router,
27 | stats_router,
28 | backups_router,
29 | sender_router,
30 | coupons_router,
31 | restart_router,
32 | bans_router,
33 | ads_router,
34 | )
35 |
--------------------------------------------------------------------------------
/handlers/admin/ads/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .ads_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/ads/ads_handler.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.enums import ParseMode
3 | from aiogram.fsm.context import FSMContext
4 | from aiogram.fsm.state import State, StatesGroup
5 | from aiogram.types import CallbackQuery, Message
6 |
7 | from config import USERNAME_BOT
8 | from database import (
9 | create_tracking_source,
10 | get_all_tracking_sources,
11 | get_tracking_source_stats,
12 | )
13 | from filters.admin import IsAdminFilter
14 | from logger import logger
15 |
16 | from ..panel.keyboard import AdminPanelCallback
17 | from .keyboard import (
18 | AdminAdsCallback,
19 | build_ads_delete_confirm_kb,
20 | build_ads_kb,
21 | build_ads_list_kb,
22 | build_ads_stats_kb,
23 | build_cancel_input_kb,
24 | )
25 |
26 |
27 | router = Router()
28 |
29 |
30 | class AdminAdsState(StatesGroup):
31 | waiting_for_new_name = State()
32 | waiting_for_new_code = State()
33 |
34 |
35 | @router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
36 | async def handle_ads_menu(callback_query: CallbackQuery):
37 | await callback_query.message.edit_text(text="📊 Аналитика рекламы:", reply_markup=build_ads_kb())
38 |
39 |
40 | @router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
41 | async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
42 | await state.set_state(AdminAdsState.waiting_for_new_name)
43 | await callback_query.message.edit_text(
44 | "📝 Введите название новой ссылки:", reply_markup=build_cancel_input_kb()
45 | )
46 |
47 |
48 | @router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
49 | async def handle_ads_name_input(message: Message, state: FSMContext):
50 | name = message.text.strip()
51 | await state.update_data(name=name)
52 | await state.set_state(AdminAdsState.waiting_for_new_code)
53 | await message.answer(
54 | f"🔗 Введите код ссылки для:
{name}
.", reply_markup=build_cancel_input_kb()
55 | )
56 |
57 |
58 | @router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
59 | async def handle_ads_code_input(message: Message, state: FSMContext, session):
60 | code = message.text.strip()
61 | data = await state.get_data()
62 | name = data["name"]
63 | code_with_prefix = f"utm_{code}"
64 |
65 | try:
66 | await create_tracking_source(
67 | name=name, code=code_with_prefix, type_="utm", created_by=message.from_user.id, session=session
68 | )
69 | stats = await get_tracking_source_stats(code_with_prefix, session)
70 | if not stats:
71 | await message.answer("❌ Источник не найден или не содержит данных.")
72 | return
73 | msg = format_ads_stats(stats, USERNAME_BOT)
74 | await message.answer(
75 | text=msg,
76 | reply_markup=build_ads_stats_kb(code_with_prefix),
77 | )
78 |
79 | except Exception as e:
80 | logger.error(f"Ошибка при создании ссылки: {e}")
81 | await message.answer("❌ Произошла ошибка при создании ссылки.")
82 | finally:
83 | await state.clear()
84 |
85 |
86 | @router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
87 | async def handle_ads_list(callback_query: CallbackQuery, session):
88 | try:
89 | ads = await get_all_tracking_sources(session)
90 | reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
91 | await callback_query.message.edit_text(
92 | "📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
93 | )
94 | except Exception as e:
95 | logger.error(f"Ошибка при получении списка UTM: {e}")
96 | await callback_query.message.edit_text("Произошла ошибка при получении списка.")
97 |
98 |
99 | @router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
100 | async def handle_ads_view(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
101 | code = callback_data.code
102 | try:
103 | stats = await get_tracking_source_stats(code, session)
104 | if not stats:
105 | await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
106 | return
107 | msg = format_ads_stats(stats, USERNAME_BOT)
108 | await callback_query.message.edit_text(
109 | text=msg, reply_markup=build_ads_stats_kb(code), parse_mode=ParseMode.HTML
110 | )
111 | except Exception as e:
112 | logger.error(f"Ошибка при просмотре статистики: {e}")
113 | await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
114 |
115 |
116 | @router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
117 | async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
118 | code = callback_data.code
119 | await callback_query.message.edit_text(
120 | text=f"Вы уверены, что хотите удалить ссылку {code}
?",
121 | reply_markup=build_ads_delete_confirm_kb(code),
122 | )
123 |
124 |
125 | @router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
126 | async def handle_ads_delete(callback_query: CallbackQuery, callback_data: AdminAdsCallback, session):
127 | code = callback_data.code
128 | try:
129 | await session.execute("UPDATE users SET source_code = NULL WHERE source_code = $1", code)
130 | await session.execute("DELETE FROM tracking_sources WHERE code = $1", code)
131 | await callback_query.message.edit_text(f"🗑️ Ссылка {code}
удалена.", reply_markup=build_ads_kb())
132 | except Exception as e:
133 | logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
134 | await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
135 |
136 |
137 | def format_ads_stats(stats: dict, username_bot: str) -> str:
138 | return (
139 | f"📊 Статистика по рекламной ссылке\n\n"
140 | f"📌 Название: {stats['name']}\n"
141 | f"🔗 Ссылка: https://t.me/{username_bot}?start={stats['code']}
\n"
142 | f"🕓 Создана: {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
143 | f"💡 Активность:\n"
144 | f"└ 🆕 Регистраций: {stats.get('registrations', 0)}\n"
145 | f"└ 🧪 Триалов: {stats.get('trials', 0)}\n"
146 | f"\n💰 Финансовая информация:\n"
147 | f"└ 💳 Покупок: {stats.get('payments', 0)}\n\n"
148 | f"Просмотр статистики и управление рекламными ссылками."
149 | )
150 |
151 |
152 | @router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
153 | async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
154 | await state.clear()
155 | await callback_query.message.edit_text(text="📊 Аналитика рекламы:", reply_markup=build_ads_kb())
156 |
--------------------------------------------------------------------------------
/handlers/admin/ads/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
3 | from aiogram.utils.keyboard import InlineKeyboardBuilder
4 |
5 | from ..panel.keyboard import build_admin_back_btn
6 |
7 |
8 | class AdminAdsCallback(CallbackData, prefix="admin_ads"):
9 | action: str
10 | code: str | None = None
11 |
12 |
13 | def build_ads_kb() -> InlineKeyboardMarkup:
14 | builder = InlineKeyboardBuilder()
15 | builder.button(text="➕ Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
16 | builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
17 | builder.row(build_admin_back_btn())
18 | builder.adjust(1)
19 | return builder.as_markup()
20 |
21 |
22 | def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
23 | builder = InlineKeyboardBuilder()
24 |
25 | for ad in ads:
26 | builder.button(
27 | text=f"📎 {ad['name']}",
28 | callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
29 | )
30 |
31 | pagination_buttons = []
32 | if current_page > 1:
33 | pagination_buttons.append(
34 | InlineKeyboardButton(
35 | text="⬅️ Назад",
36 | callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
37 | )
38 | )
39 | if current_page < total_pages:
40 | pagination_buttons.append(
41 | InlineKeyboardButton(
42 | text="Вперед ➡️",
43 | callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
44 | )
45 | )
46 | if pagination_buttons:
47 | builder.row(*pagination_buttons)
48 |
49 | builder.row(build_admin_back_btn("ads"))
50 | return builder.as_markup()
51 |
52 |
53 | def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
54 | builder = InlineKeyboardBuilder()
55 | builder.button(text="🗑️ Удалить", callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack())
56 | builder.row(build_admin_back_btn("ads"))
57 | return builder.as_markup()
58 |
59 |
60 | def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
61 | builder = InlineKeyboardBuilder()
62 | builder.button(
63 | text="✅ Да, удалить",
64 | callback_data=AdminAdsCallback(
65 | action="delete",
66 | code=code,
67 | ).pack(),
68 | )
69 | builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="view", code=code).pack())
70 | builder.adjust(1)
71 | return builder.as_markup()
72 |
73 |
74 | def build_cancel_input_kb() -> InlineKeyboardMarkup:
75 | builder = InlineKeyboardBuilder()
76 | builder.button(text="❌ Отмена", callback_data=AdminAdsCallback(action="cancel_input", code="none").pack())
77 | return builder.as_markup()
78 |
--------------------------------------------------------------------------------
/handlers/admin/backups/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .backups_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/backups/backups_handler.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.types import CallbackQuery
3 |
4 | from backup import backup_database
5 | from filters.admin import IsAdminFilter
6 |
7 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
8 |
9 |
10 | router = Router()
11 |
12 |
13 | @router.callback_query(
14 | AdminPanelCallback.filter(F.action == "backups"),
15 | IsAdminFilter(),
16 | )
17 | async def handle_backups(callback_query: CallbackQuery):
18 | kb = build_admin_back_kb("management")
19 |
20 | await callback_query.message.edit_text(
21 | text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
22 | )
23 |
24 | exception = await backup_database()
25 |
26 | if exception:
27 | text = f"❌ Ошибка при создании резервной копии: {exception}"
28 | else:
29 | text = "✅ Резервная копия успешно создана и отправлена администраторам."
30 |
31 | await callback_query.message.edit_text(text=text, reply_markup=kb)
32 |
--------------------------------------------------------------------------------
/handlers/admin/bans/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .bans_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/bans/bans_handler.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from aiogram import F, Router
4 | from aiogram.types import BufferedInputFile, CallbackQuery
5 |
6 | from database import delete_user_data
7 | from filters.admin import IsAdminFilter
8 |
9 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
10 | from .keyboard import build_bans_kb
11 |
12 |
13 | router = Router()
14 |
15 |
16 | @router.callback_query(
17 | AdminPanelCallback.filter(F.action == "bans"),
18 | IsAdminFilter(),
19 | )
20 | async def handle_bans(callback_query: CallbackQuery):
21 | text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
22 |
23 | await callback_query.message.edit_text(
24 | text=text,
25 | reply_markup=build_bans_kb(),
26 | )
27 |
28 |
29 | @router.callback_query(
30 | AdminPanelCallback.filter(F.action == "bans_export"),
31 | IsAdminFilter(),
32 | )
33 | async def handle_bans_export(callback_query: CallbackQuery, session: Any):
34 | kb = build_admin_back_kb("management")
35 |
36 | try:
37 | banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
38 |
39 | import csv
40 | import io
41 |
42 | csv_output = io.StringIO()
43 | writer = csv.writer(csv_output)
44 | writer.writerow(["tg_id", "blocked_at"])
45 | for user in banned_users:
46 | writer.writerow([user["tg_id"], user["blocked_at"]])
47 |
48 | csv_output.seek(0)
49 |
50 | document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
51 |
52 | await callback_query.message.answer_document(
53 | document=document,
54 | caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
55 | )
56 | except Exception as e:
57 | await callback_query.message.answer(
58 | text=f"❗ Произошла ошибка при экспорте: {e}",
59 | reply_markup=kb,
60 | )
61 |
62 |
63 | @router.callback_query(
64 | AdminPanelCallback.filter(F.action == "bans_delete_banned"),
65 | IsAdminFilter(),
66 | )
67 | async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
68 | kb = build_admin_back_kb("bans")
69 |
70 | try:
71 | blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
72 | blocked_ids = [record["tg_id"] for record in blocked_users]
73 |
74 | if not blocked_ids:
75 | await callback_query.message.answer(
76 | text="📂 Нет заблокировавших пользователей для удаления.",
77 | reply_markup=kb,
78 | )
79 | return
80 |
81 | for tg_id in blocked_ids:
82 | await delete_user_data(session, tg_id)
83 |
84 | await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
85 |
86 | await callback_query.message.answer(
87 | text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
88 | reply_markup=kb,
89 | )
90 | except Exception as e:
91 | await callback_query.message.answer(
92 | text=f"❗ Произошла ошибка при удалении записей: {e}",
93 | reply_markup=kb,
94 | )
95 |
--------------------------------------------------------------------------------
/handlers/admin/bans/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import InlineKeyboardMarkup
2 | from aiogram.utils.keyboard import InlineKeyboardBuilder
3 |
4 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
5 |
6 |
7 | def build_bans_kb() -> InlineKeyboardMarkup:
8 | builder = InlineKeyboardBuilder()
9 | builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
10 | builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
11 | builder.row(build_admin_back_btn("management"))
12 | builder.adjust(1)
13 | return builder.as_markup()
14 |
--------------------------------------------------------------------------------
/handlers/admin/clusters/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .clusters_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/clusters/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
3 | from aiogram.utils.keyboard import InlineKeyboardBuilder
4 |
5 | from handlers.buttons import BACK
6 |
7 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
8 | from ..servers.keyboard import AdminServerCallback
9 |
10 |
11 | class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
12 | action: str
13 | data: str | None = None
14 |
15 |
16 | def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
17 | builder = InlineKeyboardBuilder()
18 |
19 | cluster_names = list(servers.keys())
20 | for i in range(0, len(cluster_names), 2):
21 | builder.row(*[
22 | InlineKeyboardButton(
23 | text=f"⚙️ {name}",
24 | callback_data=AdminClusterCallback(action="manage", data=name).pack(),
25 | )
26 | for name in cluster_names[i : i + 2]
27 | ])
28 |
29 | builder.row(
30 | InlineKeyboardButton(text="➕ Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
31 | )
32 |
33 | builder.row(build_admin_back_btn())
34 |
35 | return builder.as_markup()
36 |
37 |
38 | def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
39 | builder = InlineKeyboardBuilder()
40 |
41 | for server in cluster_servers:
42 | builder.row(
43 | InlineKeyboardButton(
44 | text=f"🌍 {server['server_name']}",
45 | callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
46 | )
47 | )
48 |
49 | builder.row(
50 | InlineKeyboardButton(
51 | text="➕ Добавить сервер",
52 | callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
53 | )
54 | )
55 |
56 | builder.row(build_admin_back_btn("clusters"))
57 | return builder.as_markup()
58 |
59 |
60 | def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
61 | builder = InlineKeyboardBuilder()
62 |
63 | builder.row(
64 | InlineKeyboardButton(
65 | text="📡 Серверы",
66 | callback_data=f"cluster_servers|{cluster_name}",
67 | )
68 | )
69 | builder.row(
70 | InlineKeyboardButton(
71 | text="🌐 Доступность",
72 | callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
73 | )
74 | )
75 | builder.row(
76 | InlineKeyboardButton(
77 | text="🔄 Синхронизация",
78 | callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
79 | )
80 | )
81 | builder.row(
82 | InlineKeyboardButton(
83 | text="💾 Создать бэкап",
84 | callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
85 | )
86 | )
87 | builder.row(
88 | InlineKeyboardButton(
89 | text="⏳ Добавить время",
90 | callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
91 | )
92 | )
93 | builder.row(
94 | InlineKeyboardButton(
95 | text="✏️ Сменить название",
96 | callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
97 | )
98 | )
99 | builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
100 |
101 | return builder.as_markup()
102 |
103 |
104 | def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
105 | builder = InlineKeyboardBuilder()
106 |
107 | for server in cluster_servers:
108 | builder.row(
109 | InlineKeyboardButton(
110 | text=f"🔄 Синхронизировать {server['server_name']}",
111 | callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
112 | )
113 | )
114 |
115 | builder.row(
116 | InlineKeyboardButton(
117 | text="📍 Синхронизировать кластер",
118 | callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
119 | )
120 | )
121 |
122 | builder.row(build_admin_back_btn("clusters"))
123 |
124 | return builder.as_markup()
125 |
126 |
127 | def build_panel_type_kb() -> InlineKeyboardMarkup:
128 | builder = InlineKeyboardBuilder()
129 | builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
130 | builder.button(text="🌀 Remnawave", callback_data=AdminClusterCallback(action="panel_remnawave").pack())
131 | builder.row(build_admin_back_btn("clusters"))
132 | return builder.as_markup()
133 |
--------------------------------------------------------------------------------
/handlers/admin/coupons/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .coupons_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/coupons/keyboard.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from aiogram.filters.callback_data import CallbackData
4 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
5 | from aiogram.utils.keyboard import InlineKeyboardBuilder
6 |
7 | from handlers.buttons import BACK
8 | from handlers.utils import format_days
9 |
10 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
11 |
12 |
13 | class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
14 | coupon_code: str
15 | confirm: bool | None = None
16 |
17 |
18 | def build_coupons_kb() -> InlineKeyboardMarkup:
19 | builder = InlineKeyboardBuilder()
20 | builder.button(text="➕ Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
21 | builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
22 | builder.row(build_admin_back_btn())
23 | return builder.as_markup()
24 |
25 |
26 | def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
27 | builder = InlineKeyboardBuilder()
28 |
29 | for coupon in coupons:
30 | coupon_code = coupon["code"]
31 | builder.button(
32 | text=f"❌{coupon_code}",
33 | callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
34 | )
35 |
36 | pagination_buttons = []
37 | if current_page > 1:
38 | pagination_buttons.append(
39 | InlineKeyboardButton(
40 | text=BACK,
41 | callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
42 | )
43 | )
44 | if current_page < total_pages:
45 | pagination_buttons.append(
46 | InlineKeyboardButton(
47 | text="Вперед ➡️",
48 | callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
49 | )
50 | )
51 | if pagination_buttons:
52 | builder.row(*pagination_buttons)
53 |
54 | builder.row(build_admin_back_btn("coupons"))
55 | builder.adjust(2)
56 | return builder.as_markup()
57 |
58 |
59 | def format_coupons_list(coupons: list, username_bot: str) -> str:
60 | coupon_list = "📜 Список всех купонов:\n\n"
61 | for coupon in coupons:
62 | value_text = (
63 | f"💰 Сумма: {coupon['amount']} рублей"
64 | if coupon["amount"] > 0
65 | else f"⏳ {format_days(coupon['days'])}"
66 | )
67 | coupon_list += (
68 | f"🏷️ Код: {coupon['code']}\n"
69 | f"{value_text}\n"
70 | f"🔢 Лимит использования: {coupon['usage_limit']} раз\n"
71 | f"✅ Использовано: {coupon['usage_count']} раз\n"
72 | f"🔗 Ссылка: https://t.me/{username_bot}?start=coupons_{coupon['code']}
\n\n"
73 | )
74 | return coupon_list
75 |
--------------------------------------------------------------------------------
/handlers/admin/management/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .management_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/management/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import InlineKeyboardMarkup
2 | from aiogram.utils.keyboard import InlineKeyboardBuilder
3 |
4 | from middlewares import maintenance
5 |
6 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
7 |
8 |
9 | def build_management_kb() -> InlineKeyboardMarkup:
10 | builder = InlineKeyboardBuilder()
11 | builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
12 | builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
13 | builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
14 | builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
15 | builder.button(text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack())
16 | maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
17 | builder.button(text=maintenance_text, callback_data=AdminPanelCallback(action="toggle_maintenance").pack())
18 |
19 | builder.row(build_admin_back_btn())
20 | builder.adjust(1)
21 | return builder.as_markup()
22 |
--------------------------------------------------------------------------------
/handlers/admin/management/management_handler.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.fsm.context import FSMContext
3 | from aiogram.fsm.state import State, StatesGroup
4 | from aiogram.types import CallbackQuery, Message
5 | from asyncpg import Connection
6 |
7 | from filters.admin import IsAdminFilter
8 | from logger import logger
9 | from middlewares import maintenance
10 |
11 | from ..panel.keyboard import build_admin_back_kb
12 | from .keyboard import AdminPanelCallback, build_management_kb
13 |
14 |
15 | router = Router()
16 |
17 |
18 | class AdminManagementStates(StatesGroup):
19 | waiting_for_new_domain = State()
20 |
21 |
22 | @router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
23 | async def handle_management(callback_query: CallbackQuery):
24 | await callback_query.message.edit_text(
25 | text="🤖 Управление ботом",
26 | reply_markup=build_management_kb(),
27 | )
28 |
29 |
30 | @router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
31 | async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
32 | """Запрашивает у администратора новый домен."""
33 | await state.set_state(AdminManagementStates.waiting_for_new_domain)
34 | await callback_query.message.edit_text(
35 | text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
36 | )
37 |
38 |
39 | @router.message(AdminManagementStates.waiting_for_new_domain)
40 | async def process_new_domain(message: Message, state: FSMContext, session: Connection):
41 | """Обновляет домен в таблице keys."""
42 | new_domain = message.text.strip()
43 | logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
44 |
45 | if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
46 | logger.warning("[DomainChange] Некорректный домен")
47 | await message.answer(
48 | "🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
49 | reply_markup=build_admin_back_kb("admin"),
50 | )
51 | return
52 |
53 | new_domain_url = f"https://{new_domain}"
54 | logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
55 |
56 | query = """
57 | UPDATE keys
58 | SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
59 | WHERE key NOT LIKE $1 || '%'
60 | """
61 | try:
62 | await session.execute(query, new_domain_url)
63 | logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
64 | except Exception as e:
65 | logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
66 | await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
67 | return
68 |
69 | try:
70 | sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
71 | logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
72 | except Exception as e:
73 | logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
74 |
75 | await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
76 | await state.clear()
77 |
78 |
79 | @router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
80 | async def toggle_maintenance_mode(callback: CallbackQuery):
81 | maintenance.maintenance_mode = not maintenance.maintenance_mode
82 |
83 | new_status = "включён" if maintenance.maintenance_mode else "выключен"
84 | await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
85 |
86 | await callback.message.edit_reply_markup(reply_markup=build_management_kb())
87 |
--------------------------------------------------------------------------------
/handlers/admin/panel/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .panel_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/panel/keyboard.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from aiogram.filters.callback_data import CallbackData
4 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
5 | from aiogram.utils.keyboard import InlineKeyboardBuilder
6 |
7 | from handlers.buttons import BACK, MAIN_MENU
8 |
9 |
10 | class AdminPanelCallback(CallbackData, prefix="admin_panel"):
11 | action: str
12 | page: int
13 |
14 | def __init__(self, /, **data: Any) -> None:
15 | if "page" not in data or data["page"] is None:
16 | data["page"] = 1
17 | super().__init__(**data)
18 |
19 |
20 | def build_panel_kb() -> InlineKeyboardMarkup:
21 | builder = InlineKeyboardBuilder()
22 | builder.button(text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack())
23 | builder.button(text="🔑 Поиск по названию ключа", callback_data=AdminPanelCallback(action="search_key").pack())
24 | builder.row(
25 | InlineKeyboardButton(text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()),
26 | InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
27 | )
28 | builder.row(
29 | InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
30 | InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
31 | )
32 | builder.row(
33 | InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
34 | InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
35 | )
36 | builder.button(text=MAIN_MENU, callback_data="profile")
37 | builder.adjust(1, 1, 2, 2, 2, 1)
38 | return builder.as_markup()
39 |
40 |
41 | def build_restart_kb() -> InlineKeyboardMarkup:
42 | builder = InlineKeyboardBuilder()
43 | builder.button(text="✅ Да, перезагрузить", callback_data=AdminPanelCallback(action="restart_confirm").pack())
44 | builder.row(build_admin_back_btn())
45 | builder.adjust(1)
46 | return builder.as_markup()
47 |
48 |
49 | def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
50 | return build_admin_singleton_kb(BACK, action)
51 |
52 |
53 | def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
54 | builder = InlineKeyboardBuilder()
55 | builder.row(build_admin_btn(text, action))
56 | return builder.as_markup()
57 |
58 |
59 | def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
60 | return build_admin_btn(BACK, action)
61 |
62 |
63 | def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
64 | return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
65 |
--------------------------------------------------------------------------------
/handlers/admin/panel/panel_handler.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.exceptions import TelegramBadRequest
3 | from aiogram.filters import Command
4 | from aiogram.fsm.context import FSMContext
5 | from aiogram.types import CallbackQuery, Message
6 |
7 | from bot import version
8 | from filters.admin import IsAdminFilter
9 | from logger import logger
10 |
11 | from .keyboard import AdminPanelCallback, build_panel_kb
12 |
13 |
14 | router = Router()
15 |
16 |
17 | @router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
18 | async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
19 | text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
20 |
21 | await state.clear()
22 |
23 | if callback_query.message.text:
24 | try:
25 | await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
26 | except TelegramBadRequest as e:
27 | if "message is not modified" in str(e):
28 | logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
29 | else:
30 | raise
31 | else:
32 | try:
33 | await callback_query.message.delete()
34 | except Exception as e:
35 | logger.error(f"Ошибка при удалении сообщения: {e}")
36 |
37 | await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
38 |
39 |
40 | @router.callback_query(F.data == "admin", IsAdminFilter())
41 | async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
42 | await handle_admin_message(callback_query.message, state)
43 |
44 |
45 | @router.message(Command("admin"), IsAdminFilter())
46 | async def handle_admin_message(message: Message, state: FSMContext):
47 | text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
48 |
49 | await state.clear()
50 | await message.answer(text=text, reply_markup=build_panel_kb())
51 |
--------------------------------------------------------------------------------
/handlers/admin/restart/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .restart_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/restart/restart_handler.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 |
3 | from aiogram import F, Router
4 | from aiogram.types import CallbackQuery
5 |
6 | from filters.admin import IsAdminFilter
7 |
8 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
9 |
10 |
11 | router = Router()
12 |
13 |
14 | @router.callback_query(
15 | AdminPanelCallback.filter(F.action == "restart"),
16 | IsAdminFilter(),
17 | )
18 | async def handle_restart(callback_query: CallbackQuery):
19 | await callback_query.message.edit_text(
20 | text="🤔 Вы уверены, что хотите перезагрузить бота?",
21 | reply_markup=build_restart_kb(),
22 | )
23 |
24 |
25 | @router.callback_query(
26 | AdminPanelCallback.filter(F.action == "restart_confirm"),
27 | IsAdminFilter(),
28 | )
29 | async def handle_restart_confirm(callback_query: CallbackQuery):
30 | kb = build_admin_back_kb()
31 | try:
32 | subprocess.run(
33 | ["sudo", "systemctl", "restart", "bot.service"],
34 | check=True,
35 | capture_output=True,
36 | text=True,
37 | )
38 | await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
39 | except subprocess.CalledProcessError:
40 | await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
41 | except Exception as e:
42 | await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
43 |
--------------------------------------------------------------------------------
/handlers/admin/sender/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .sender_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/sender/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
3 | from aiogram.utils.keyboard import InlineKeyboardBuilder
4 |
5 | from ..panel.keyboard import build_admin_back_btn
6 |
7 |
8 | class AdminSenderCallback(CallbackData, prefix="admin_sender"):
9 | type: str
10 | data: str | None = None
11 |
12 |
13 | def build_sender_kb() -> InlineKeyboardMarkup:
14 | builder = InlineKeyboardBuilder()
15 |
16 | builder.row(InlineKeyboardButton(text="👥 Все пользователи", callback_data=AdminSenderCallback(type="all").pack()))
17 | builder.row(
18 | InlineKeyboardButton(text="✅ С подпиской", callback_data=AdminSenderCallback(type="subscribed").pack()),
19 | InlineKeyboardButton(text="❌ Без подписки", callback_data=AdminSenderCallback(type="unsubscribed").pack()),
20 | )
21 | builder.row(
22 | InlineKeyboardButton(
23 | text="📍 Не использовавшие триал", callback_data=AdminSenderCallback(type="untrial").pack()
24 | )
25 | )
26 | builder.row(InlineKeyboardButton(text="🔥 Горячие лиды", callback_data=AdminSenderCallback(type="hotleads").pack()))
27 | builder.row(
28 | InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
29 | )
30 | builder.row(build_admin_back_btn())
31 |
32 | return builder.as_markup()
33 |
34 |
35 | def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
36 | builder = InlineKeyboardBuilder()
37 |
38 | for cluster in clusters:
39 | name = cluster["cluster_name"]
40 | builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
41 |
42 | builder.adjust(2)
43 | builder.row(build_admin_back_btn())
44 |
45 | return builder.as_markup()
46 |
--------------------------------------------------------------------------------
/handlers/admin/sender/sender_handler.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | from aiogram import F, Router
5 | from aiogram.fsm.context import FSMContext
6 | from aiogram.fsm.state import State, StatesGroup
7 | from aiogram.types import CallbackQuery, Message
8 |
9 | from filters.admin import IsAdminFilter
10 | from logger import logger
11 |
12 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
13 | from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
14 |
15 |
16 | router = Router()
17 |
18 |
19 | class AdminSender(StatesGroup):
20 | waiting_for_message = State()
21 |
22 |
23 | @router.callback_query(
24 | AdminPanelCallback.filter(F.action == "sender"),
25 | IsAdminFilter(),
26 | )
27 | async def handle_sender(callback_query: CallbackQuery):
28 | await callback_query.message.edit_text(
29 | text="✍️ Выберите группу пользователей для рассылки:",
30 | reply_markup=build_sender_kb(),
31 | )
32 |
33 |
34 | @router.callback_query(
35 | AdminSenderCallback.filter(F.type != "cluster-select"),
36 | IsAdminFilter(),
37 | )
38 | async def handle_sender_callback_text(
39 | callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
40 | ):
41 | await callback_query.message.edit_text(
42 | text="✍️ Введите текст сообщения для рассылки:",
43 | reply_markup=build_admin_back_kb("sender"),
44 | )
45 | await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
46 | await state.set_state(AdminSender.waiting_for_message)
47 |
48 |
49 | @router.callback_query(
50 | AdminSenderCallback.filter(F.type == "cluster-select"),
51 | IsAdminFilter(),
52 | )
53 | async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
54 | clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
55 | await callback_query.message.answer(
56 | "✍️ Выберите кластер для рассылки сообщений:",
57 | reply_markup=build_clusters_kb(clusters),
58 | )
59 |
60 |
61 | @router.message(AdminSender.waiting_for_message, IsAdminFilter())
62 | async def handle_message_input(message: Message, state: FSMContext, session: Any):
63 | """
64 | Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
65 | """
66 | text_message = message.html_text if message.text else None
67 | photo = message.photo[-1].file_id if message.photo else None
68 | photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
69 |
70 | if not text_message and message.caption:
71 | text_message = message.caption
72 |
73 | if not text_message and not photo and not photo_url:
74 | await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
75 | return
76 |
77 | state_data = await state.get_data()
78 | send_to = state_data.get("type", "all")
79 |
80 | now = int(datetime.utcnow().timestamp() * 1000)
81 |
82 | if send_to == "subscribed":
83 | tg_ids = await session.fetch(
84 | """
85 | SELECT DISTINCT u.tg_id
86 | FROM users u
87 | JOIN keys k ON u.tg_id = k.tg_id
88 | WHERE k.expiry_time > $1
89 | """,
90 | now,
91 | )
92 | elif send_to == "unsubscribed":
93 | tg_ids = await session.fetch(
94 | """
95 | SELECT u.tg_id
96 | FROM users u
97 | LEFT JOIN keys k ON u.tg_id = k.tg_id
98 | GROUP BY u.tg_id
99 | HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
100 | """,
101 | now,
102 | )
103 | elif send_to == "untrial":
104 | tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users WHERE tg_id NOT IN (SELECT tg_id FROM keys)")
105 | elif send_to == "cluster":
106 | cluster_name = state_data.get("cluster_name")
107 | tg_ids = await session.fetch(
108 | """
109 | SELECT DISTINCT u.tg_id
110 | FROM users u
111 | JOIN keys k ON u.tg_id = k.tg_id
112 | JOIN servers s ON k.server_id = s.cluster_name
113 | WHERE s.cluster_name = $1
114 | """,
115 | cluster_name,
116 | )
117 | elif send_to == "hotleads":
118 | tg_ids = await session.fetch(
119 | """
120 | SELECT DISTINCT u.tg_id
121 | FROM users u
122 | JOIN payments p ON u.tg_id = p.tg_id
123 | LEFT JOIN keys k ON u.tg_id = k.tg_id
124 | WHERE p.status = 'success'
125 | AND k.tg_id IS NULL
126 | """
127 | )
128 | else:
129 | tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM users")
130 |
131 | total_users = len(tg_ids)
132 | success_count = 0
133 |
134 | text = f"📤 Рассылка начата!\n👥 Количество получателей: {total_users}"
135 |
136 | await message.answer(text=text)
137 |
138 | for record in tg_ids:
139 | tg_id = record["tg_id"]
140 | try:
141 | if photo or photo_url:
142 | await message.bot.send_photo(
143 | chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
144 | )
145 | else:
146 | await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
147 |
148 | success_count += 1
149 | except Exception as e:
150 | logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
151 |
152 | text = (
153 | f"📤 Рассылка завершена!\n\n"
154 | f"👥 Количество получателей: {total_users}\n"
155 | f"✅ Доставлено: {success_count}\n"
156 | f"❌ Не доставлено: {total_users - success_count}"
157 | )
158 |
159 | await message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
160 | await state.clear()
161 |
--------------------------------------------------------------------------------
/handlers/admin/servers/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .servers_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/servers/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from aiogram.types import InlineKeyboardMarkup
3 | from aiogram.utils.keyboard import InlineKeyboardBuilder
4 |
5 | from handlers.buttons import BACK
6 |
7 |
8 | class AdminServerCallback(CallbackData, prefix="admin_server"):
9 | action: str
10 | data: str
11 |
12 |
13 | def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
14 | from ..clusters.keyboard import AdminClusterCallback
15 |
16 | builder = InlineKeyboardBuilder()
17 |
18 | toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
19 | toggle_action = "disable" if enabled else "enable"
20 |
21 | builder.button(text=toggle_text, callback_data=AdminServerCallback(action=toggle_action, data=server_name).pack())
22 |
23 | builder.button(
24 | text="📈 Задать лимит", callback_data=AdminServerCallback(action="set_limit", data=server_name).pack()
25 | )
26 |
27 | builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
28 |
29 | builder.button(
30 | text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack()
31 | )
32 |
33 | builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
34 |
35 | builder.adjust(1)
36 | return builder.as_markup()
37 |
--------------------------------------------------------------------------------
/handlers/admin/stats/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .stats_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/admin/stats/keyboard.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import InlineKeyboardMarkup
2 | from aiogram.utils.keyboard import InlineKeyboardBuilder
3 |
4 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
5 |
6 |
7 | def build_stats_kb() -> InlineKeyboardMarkup:
8 | builder = InlineKeyboardBuilder()
9 | builder.button(text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack())
10 | builder.button(
11 | text="📥 Выгрузить пользователей в CSV",
12 | callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
13 | )
14 | builder.button(
15 | text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
16 | )
17 | builder.button(
18 | text="📥 Выгрузить подписки в CSV",
19 | callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
20 | )
21 | builder.button(
22 | text="📥 Выгрузить горящих лидов", callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack()
23 | )
24 | builder.row(build_admin_back_btn())
25 | builder.adjust(1)
26 | return builder.as_markup()
27 |
--------------------------------------------------------------------------------
/handlers/admin/stats/stats_handler.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | import pytz
5 |
6 | from aiogram import F, Router
7 | from aiogram.exceptions import TelegramBadRequest
8 | from aiogram.types import CallbackQuery
9 |
10 | from filters.admin import IsAdminFilter
11 | from logger import logger
12 | from utils.csv_export import export_hot_leads_csv, export_keys_csv, export_payments_csv, export_users_csv
13 |
14 | from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
15 | from .keyboard import build_stats_kb
16 |
17 |
18 | router = Router()
19 |
20 |
21 | @router.callback_query(
22 | AdminPanelCallback.filter(F.action == "stats"),
23 | IsAdminFilter(),
24 | )
25 | async def handle_stats(callback_query: CallbackQuery, session: Any):
26 | try:
27 | total_users = await session.fetchval("SELECT COUNT(*) FROM users")
28 | total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
29 | total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
30 |
31 | total_payments_today = int(
32 | await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
33 | )
34 | total_payments_week = int(
35 | await session.fetchval(
36 | "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
37 | )
38 | )
39 | total_payments_month = int(
40 | await session.fetchval(
41 | "SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
42 | )
43 | )
44 | total_payments_last_month = int(
45 | await session.fetchval(
46 | """
47 | SELECT COALESCE(SUM(amount), 0)
48 | FROM payments
49 | WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month')
50 | AND created_at < date_trunc('month', CURRENT_DATE)
51 | """
52 | )
53 | )
54 | total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
55 |
56 | all_keys = await session.fetch("SELECT created_at, expiry_time FROM keys")
57 |
58 | def count_subscriptions_by_duration(keys):
59 | periods = {"trial": 0, "1": 0, "3": 0, "6": 0, "12": 0}
60 | for key in keys:
61 | try:
62 | duration_days = (key["expiry_time"] - key["created_at"]) / (1000 * 60 * 60 * 24)
63 |
64 | if duration_days <= 29:
65 | periods["trial"] += 1
66 | elif duration_days <= 89:
67 | periods["1"] += 1
68 | elif duration_days <= 179:
69 | periods["3"] += 1
70 | elif duration_days <= 359:
71 | periods["6"] += 1
72 | else:
73 | periods["12"] += 1
74 | except Exception as e:
75 | logger.error(f"Error processing key duration: {e}")
76 | continue
77 | return periods
78 |
79 | subs_all_time = count_subscriptions_by_duration(all_keys)
80 |
81 | registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
82 | registrations_week = await session.fetchval(
83 | "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
84 | )
85 | registrations_month = await session.fetchval(
86 | "SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
87 | )
88 |
89 | users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
90 |
91 | active_keys = await session.fetchval(
92 | "SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
93 | int(datetime.utcnow().timestamp() * 1000),
94 | )
95 | expired_keys = total_keys - active_keys
96 | moscow_tz = pytz.timezone("Europe/Moscow")
97 | update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
98 |
99 | hot_leads_count = await session.fetchval("""
100 | SELECT COUNT(DISTINCT u.tg_id)
101 | FROM users u
102 | JOIN payments p ON u.tg_id = p.tg_id
103 | LEFT JOIN keys k ON u.tg_id = k.tg_id
104 | WHERE p.status = 'success'
105 | AND k.tg_id IS NULL
106 | """)
107 |
108 | stats_message = (
109 | "📊 Статистика проекта\n\n"
110 | "👤 Пользователи:\n"
111 | f"├ 🗓️ За день: {registrations_today}\n"
112 | f"├ 📆 За неделю: {registrations_week}\n"
113 | f"├ 🗓️ За месяц: {registrations_month}\n"
114 | f"└ 🌐 Всего: {total_users}\n\n"
115 | "💡 Активность:\n"
116 | f"└ 👥 Сегодня были активны: {users_updated_today}\n\n"
117 | "🤝 Реферальная система:\n"
118 | f"└ 👥 Всего привлечено: {total_referrals}\n\n"
119 | "🔐 Подписки:\n"
120 | f"├ 📦 Всего сгенерировано: {total_keys}\n"
121 | f"├ ✅ Активных: {active_keys}\n"
122 | f"├ ❌ Просроченных: {expired_keys}\n"
123 | f"└ 📋 По срокам:\n"
124 | f" • 🎁 Триал: {subs_all_time['trial']}\n"
125 | f" • 🗓️ 1 мес: {subs_all_time['1']}\n"
126 | f" • 🗓️ 3 мес: {subs_all_time['3']}\n"
127 | f" • 🗓️ 6 мес: {subs_all_time['6']}\n"
128 | f" • 🗓️ 12 мес: {subs_all_time['12']}\n\n"
129 | "💰 Финансы:\n"
130 | f"├ 📅 За день: {total_payments_today} ₽\n"
131 | f"├ 📆 За неделю: {total_payments_week} ₽\n"
132 | f"├ 📆 За месяц: {total_payments_month} ₽\n"
133 | f"├ 📆 За прошлый месяц: {total_payments_last_month} ₽\n"
134 | f"└ 🏦 Всего: {total_payments_all_time} ₽\n\n"
135 | f"🔥 Горящие лиды: {hot_leads_count} (платили, но не продлили)\n\n"
136 | f"⏱️ Последнее обновление: {update_time}
"
137 | )
138 |
139 | await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
140 | except TelegramBadRequest as e:
141 | if "message is not modified" not in str(e):
142 | logger.error(f"Error in user_stats_menu: {e}")
143 | except Exception as e:
144 | logger.error(f"Error in user_stats_menu: {e}")
145 | await callback_query.answer("Произошла ошибка при получении статистики", show_alert=True)
146 |
147 |
148 | @router.callback_query(
149 | AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
150 | IsAdminFilter(),
151 | )
152 | async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
153 | kb = build_admin_back_kb("stats")
154 | try:
155 | export = await export_users_csv(session)
156 | await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
157 | except Exception as e:
158 | logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
159 | await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
160 |
161 |
162 | @router.callback_query(
163 | AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
164 | IsAdminFilter(),
165 | )
166 | async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
167 | kb = build_admin_back_kb("stats")
168 | try:
169 | export = await export_payments_csv(session)
170 | await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
171 | except Exception as e:
172 | logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
173 | await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
174 |
175 |
176 | @router.callback_query(
177 | AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"),
178 | IsAdminFilter(),
179 | )
180 | async def handle_export_hot_leads_csv(callback_query: CallbackQuery, session: Any):
181 | kb = build_admin_back_kb("stats")
182 | try:
183 | export = await export_hot_leads_csv(session)
184 | await callback_query.message.answer_document(document=export, caption="📥 Экспорт горящих лидов")
185 | except Exception as e:
186 | logger.error(f"Ошибка при экспорте 'горящих лидов': {e}")
187 | await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
188 |
189 |
190 | @router.callback_query(
191 | AdminPanelCallback.filter(F.action == "stats_export_keys_csv"),
192 | IsAdminFilter(),
193 | )
194 | async def handle_export_keys_csv(callback_query: CallbackQuery, session: Any):
195 | kb = build_admin_back_kb("stats")
196 | try:
197 | export = await export_keys_csv(session)
198 | await callback_query.message.answer_document(document=export, caption="📥 Экспорт подписок в CSV")
199 | except Exception as e:
200 | logger.error(f"Ошибка при экспорте подписок в CSV: {e}")
201 | await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
202 |
--------------------------------------------------------------------------------
/handlers/admin/users/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from .users_handler import router
4 |
--------------------------------------------------------------------------------
/handlers/buttons.py:
--------------------------------------------------------------------------------
1 | # Основные кнопки меню:
2 |
3 | MAIN_MENU = "👤 Личный кабинет"
4 | BACK = "⬅️ Назад"
5 | SUPPORT = "💬 Поддержка"
6 | CHANNEL = "📢 Канал"
7 | ABOUT_VPN = "💬 О сервисе"
8 | APPLY = "✅ Подтвердить"
9 | CANCEL = "❌ Отмена"
10 |
11 | # Профиль
12 |
13 | ADD_SUB = "➕ Подписка"
14 | MY_SUBS = "📱 Мои подписки"
15 | BALANCE = "💰 Баланс"
16 | INVITE = "👥 Пригласить"
17 | GIFTS = "🎁 Подарить"
18 | INSTRUCTIONS = "📘 Инструкции"
19 | TOP_FIVE = "🏆 Топ-5"
20 | TRIAL_SUB = "🎁 Пробная подписка"
21 |
22 | # Меню Оплат и баланса
23 |
24 | BALANCE_HISTORY = "📊 История пополнения"
25 | PAYMENT = "💳 Пополнить баланс"
26 | COUPON = "🎟️ Активировать купон"
27 | COUPON_RESTART = "🎟️ Попробовать другой купон"
28 | YOOKASSA = "💳 ЮКасса: быстрая оплата"
29 | YOOMONEY = "💳 ЮМани: перевод по карте"
30 | CRYPTOBOT = "💰 CryptoBot: криптовалюта"
31 | STARS = "⭐ Оплата Звездами"
32 | ROBOKASSA = "⭐ RoboKassa"
33 |
34 |
35 | # Подарки
36 |
37 | GIFT = "🎁 Подарить подписку"
38 | MY_GIFTS = "🎁 Мои подарки"
39 | GIFTS_MENU = "В меню подарков"
40 | SHARE_GIFT = "🎁 Поделиться подарком"
41 | GET_GIFT = "🎁 Получить подарок"
42 |
43 | # Создание и подключение ключей
44 |
45 | DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
46 | DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
47 | DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
48 | DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
49 | IMPORT_IOS = "🍏 Подключить"
50 | IMPORT_ANDROID = "🤖 Подключить"
51 | PC_BUTTON = "💻 Компьютеры"
52 | TV_BUTTON = "📺 Андроид TV"
53 | CONNECT_PHONE = "📱 Подключить телефон"
54 | CONNECT_DEVICE = "📲 Подключить устройство"
55 | CONNECT_WINDOWS_BUTTON = "💻 Подключить"
56 | CONNECT_MACOS_BUTTON = "🍏 Подключить"
57 | ALIAS = "✏️"
58 | UNFREEZE = "🟢 Разморозить подписку"
59 | FREEZE = "🛑 Заморозить подписку"
60 | RENEW = "⏳ Продлить"
61 | RENEW_FULL = "⏳ Продлить подписку"
62 | DELETE = "❌ Удалить"
63 | CHANGE_LOCATION = "🌍 Сменить локацию"
64 | QR = "📷 Показать QR-код"
65 | IPHONE = "🍏 Айфон"
66 | ANDROID = "🤖 Андроид"
67 | PC = "💻 Компьютер"
68 | PC_PC = "💻 Windows"
69 | PC_MACOS = "🍏 macOS"
70 | TV = "📺 Телевизор"
71 | ROUTER = "📶 Роутер"
72 | MANUAL_INSTRUCTIONS = "📖 Ручная установка"
73 | RENEW_KEY = "🔄 Продлить подписку"
74 | TV_CONTINUE = "▶ Продолжить"
75 | TV_INSTRUCTIONS = "📖 Полная инструкция"
76 |
77 |
78 | # Кнопки касс
79 |
80 | PAY = "Пополнить"
81 | PAY_2 = "Оплатить"
82 | CUSTOM_AMOUNT = "💰 Ввести свою сумму"
83 | STARS_BOT = "🤖 Бот для покупки звезд"
84 |
--------------------------------------------------------------------------------
/handlers/captcha.py:
--------------------------------------------------------------------------------
1 | import random
2 | import secrets
3 |
4 | from typing import Any
5 |
6 | from aiogram import F, Router
7 | from aiogram.fsm.context import FSMContext
8 | from aiogram.types import CallbackQuery, Message
9 | from aiogram.utils.keyboard import InlineKeyboardBuilder
10 |
11 | from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
12 | from logger import logger
13 |
14 | from .utils import edit_or_send_message
15 |
16 |
17 | router = Router()
18 |
19 |
20 | async def generate_captcha(message: Message, state: FSMContext):
21 | correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
22 | wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
23 |
24 | all_emojis = [correct_emoji] + wrong_emojis
25 | random.shuffle(all_emojis)
26 |
27 | state_data = await state.get_data()
28 |
29 | if "user_data" not in state_data:
30 | from_user = message.from_user
31 | if not from_user:
32 | logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
33 | return None
34 |
35 | await state.update_data(
36 | user_data={
37 | "tg_id": from_user.id,
38 | "username": getattr(from_user, "username", None),
39 | "first_name": getattr(from_user, "first_name", None),
40 | "last_name": getattr(from_user, "last_name", None),
41 | "language_code": getattr(from_user, "language_code", None),
42 | "is_bot": getattr(from_user, "is_bot", False),
43 | }
44 | )
45 |
46 | update_data = {
47 | "correct_emoji": correct_emoji,
48 | "message_id": message.message_id,
49 | "chat_id": message.chat.id,
50 | }
51 |
52 | state_data = await state.get_data()
53 | if "original_text" not in state_data:
54 | update_data["original_text"] = message.text
55 |
56 | await state.update_data(**update_data)
57 |
58 | builder = InlineKeyboardBuilder()
59 | for emoji in all_emojis:
60 | builder.button(text=emoji, callback_data=f"captcha_{emoji}")
61 | builder.adjust(2, 2)
62 |
63 | return {
64 | "text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
65 | "markup": builder.as_markup(),
66 | }
67 |
68 |
69 | @router.callback_query(F.data.startswith("captcha_"))
70 | async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
71 | from handlers.start import process_start_logic
72 |
73 | selected_emoji = callback.data.split("captcha_")[1]
74 | state_data = await state.get_data()
75 | correct_emoji = state_data.get("correct_emoji")
76 | original_text = state_data.get("original_text")
77 | user_data = state_data.get("user_data")
78 |
79 | target_message = callback.message
80 |
81 | if selected_emoji == correct_emoji:
82 | logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
83 | logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
84 | await process_start_logic(
85 | message=target_message,
86 | state=state,
87 | session=session,
88 | admin=admin,
89 | text_to_process=original_text,
90 | user_data=user_data,
91 | )
92 | else:
93 | logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
94 | captcha = await generate_captcha(target_message, state)
95 | if captcha:
96 | await edit_or_send_message(
97 | target_message=target_message,
98 | text=captcha["text"],
99 | reply_markup=captcha["markup"],
100 | )
101 |
--------------------------------------------------------------------------------
/handlers/donate.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.fsm.context import FSMContext
3 | from aiogram.fsm.state import State, StatesGroup
4 | from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
5 | from aiogram.utils.keyboard import InlineKeyboardBuilder
6 |
7 | from config import RUB_TO_XTR
8 | from handlers.buttons import BACK, MAIN_MENU
9 | from logger import logger
10 |
11 | from .utils import edit_or_send_message
12 |
13 |
14 | class DonateState(StatesGroup):
15 | entering_donate_amount = State()
16 | waiting_for_donate_confirmation = State()
17 | waiting_for_donate_payment = State()
18 |
19 |
20 | router = Router()
21 |
22 |
23 | @router.callback_query(F.data == "donate")
24 | async def process_donate(callback_query: CallbackQuery, state: FSMContext):
25 | await state.clear()
26 |
27 | builder = InlineKeyboardBuilder()
28 | builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
29 | builder.row(
30 | InlineKeyboardButton(
31 | text="💰 Ввести сумму доната",
32 | callback_data="enter_custom_donate_amount",
33 | )
34 | )
35 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
36 |
37 | text = (
38 | "🌟 Поддержите наш проект! 💪\n\n"
39 | "💖 Каждый донат помогает развивать и улучшать сервис. "
40 | "🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡"
41 | )
42 |
43 | await edit_or_send_message(
44 | target_message=callback_query.message,
45 | text=text,
46 | reply_markup=builder.as_markup(),
47 | )
48 |
49 |
50 | @router.callback_query(F.data == "enter_custom_donate_amount")
51 | async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
52 | builder = InlineKeyboardBuilder()
53 | builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
54 | text = "💸 Введите сумму доната в рублях:"
55 |
56 | await edit_or_send_message(
57 | target_message=callback_query.message,
58 | text=text,
59 | reply_markup=builder.as_markup(),
60 | )
61 |
62 | await state.set_state(DonateState.entering_donate_amount)
63 |
64 |
65 | @router.message(DonateState.entering_donate_amount)
66 | async def process_donate_amount_input(message: Message, state: FSMContext):
67 | if message.text.isdigit():
68 | amount = int(message.text)
69 | if amount // RUB_TO_XTR <= 0:
70 | await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
71 | return
72 |
73 | await state.update_data(amount=amount)
74 | await state.set_state(DonateState.waiting_for_donate_confirmation)
75 |
76 | try:
77 | builder = InlineKeyboardBuilder()
78 | builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
79 | builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
80 |
81 | await message.answer_invoice(
82 | title=f"Донат проекту {amount} рублей",
83 | description="Спасибо за вашу поддержку!",
84 | prices=[LabeledPrice(label="Донат", amount=int(amount // RUB_TO_XTR))],
85 | provider_token="",
86 | payload=f"{amount}_donate",
87 | currency="XTR",
88 | reply_markup=builder.as_markup(),
89 | )
90 | await state.set_state(DonateState.waiting_for_donate_payment)
91 | except Exception as e:
92 | logger.error(f"Ошибка при создании доната: {e}")
93 | else:
94 | await message.answer("Некорректная сумма. Пожалуйста, введите сумму еще раз:")
95 |
96 |
97 | @router.pre_checkout_query(DonateState.waiting_for_donate_payment)
98 | async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
99 | await pre_checkout_query.answer(ok=True)
100 |
101 |
102 | @router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
103 | async def on_successful_donate(message: Message, state: FSMContext):
104 | try:
105 | amount = float(message.successful_payment.invoice_payload.split("_")[0])
106 | builder = InlineKeyboardBuilder()
107 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
108 | await message.answer(
109 | text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
110 | reply_markup=builder.as_markup(),
111 | )
112 | await state.clear()
113 | except ValueError as e:
114 | logger.error(f"Ошибка конвертации user_id или amount: {e}")
115 | except Exception as e:
116 | logger.error(f"Произошла ошибка при обработке доната: {e}")
117 |
--------------------------------------------------------------------------------
/handlers/instructions/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .instructions import router as instructions_router
6 |
7 |
8 | router = Router(name="instructions_main_router")
9 |
10 | router.include_routers(
11 | instructions_router,
12 | )
13 |
--------------------------------------------------------------------------------
/handlers/instructions/instructions.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from typing import Any
4 |
5 | from aiogram import F, Router
6 | from aiogram.types import (
7 | CallbackQuery,
8 | InlineKeyboardButton,
9 | Message,
10 | )
11 | from aiogram.utils.keyboard import InlineKeyboardBuilder
12 |
13 | from config import (
14 | CONNECT_MACOS,
15 | CONNECT_WINDOWS,
16 | DOWNLOAD_MACOS,
17 | DOWNLOAD_PC,
18 | SUPPORT_CHAT_URL,
19 | )
20 | from database import get_key_details
21 | from handlers.buttons import (
22 | BACK,
23 | CONNECT_MACOS_BUTTON,
24 | CONNECT_WINDOWS_BUTTON,
25 | DOWNLOAD_MACOS_BUTTON,
26 | DOWNLOAD_PC_BUTTON,
27 | MAIN_MENU,
28 | PC_MACOS,
29 | PC_PC,
30 | SUPPORT,
31 | TV_CONTINUE,
32 | TV_INSTRUCTIONS,
33 | )
34 | from handlers.texts import (
35 | CHOOSE_DEVICE_TEXT,
36 | CONNECT_TV_TEXT,
37 | INSTRUCTIONS,
38 | INSTRUCTION_MACOS,
39 | INSTRUCTION_PC,
40 | KEY_MESSAGE,
41 | SUBSCRIPTION_DETAILS_TEXT,
42 | )
43 | from handlers.utils import edit_or_send_message
44 |
45 |
46 | router = Router()
47 |
48 |
49 | @router.callback_query(F.data == "instructions")
50 | @router.message(F.text == "/instructions")
51 | async def send_instructions(callback_query_or_message: CallbackQuery | Message):
52 | instructions_message = INSTRUCTIONS
53 | image_path = os.path.join("img", "instructions.jpg")
54 |
55 | builder = InlineKeyboardBuilder()
56 | builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
57 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
58 |
59 | if isinstance(callback_query_or_message, CallbackQuery):
60 | target_message = callback_query_or_message.message
61 | else:
62 | target_message = callback_query_or_message
63 |
64 | await edit_or_send_message(
65 | target_message=target_message,
66 | text=instructions_message,
67 | reply_markup=builder.as_markup(),
68 | media_path=image_path,
69 | )
70 |
71 |
72 | @router.callback_query(F.data.startswith("connect_pc|"))
73 | async def process_connect_pc(callback_query: CallbackQuery, session: Any):
74 | key_name = callback_query.data.split("|")[1]
75 | record = await get_key_details(key_name, session)
76 | if not record:
77 | builder = InlineKeyboardBuilder()
78 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
79 | await edit_or_send_message(
80 | target_message=callback_query.message,
81 | text="❌ Ключ не найден. Проверьте имя ключа. 🔍",
82 | reply_markup=builder.as_markup(),
83 | media_path=None,
84 | )
85 | return
86 |
87 | builder = InlineKeyboardBuilder()
88 | builder.row(InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}"))
89 | builder.row(InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}"))
90 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
91 |
92 | await edit_or_send_message(
93 | target_message=callback_query.message,
94 | text=CHOOSE_DEVICE_TEXT,
95 | reply_markup=builder.as_markup(),
96 | media_path=None,
97 | )
98 |
99 |
100 | @router.callback_query(F.data.startswith("windows_menu|"))
101 | async def process_windows_menu(callback_query: CallbackQuery, session: Any):
102 | key_name = callback_query.data.split("|")[1]
103 | record = await get_key_details(key_name, session)
104 | key = record["key"]
105 | key_message_text = KEY_MESSAGE.format(key)
106 | instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
107 |
108 | builder = InlineKeyboardBuilder()
109 | builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
110 | builder.row(InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}"))
111 | builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
112 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
113 |
114 | await edit_or_send_message(
115 | target_message=callback_query.message,
116 | text=instruction_message,
117 | reply_markup=builder.as_markup(),
118 | media_path=None,
119 | )
120 |
121 |
122 | @router.callback_query(F.data.startswith("macos_menu|"))
123 | async def process_macos_menu(callback_query: CallbackQuery, session: Any):
124 | key_name = callback_query.data.split("|")[1]
125 | record = await get_key_details(key_name, session)
126 | key = record["key"]
127 | key_message_text = KEY_MESSAGE.format(key)
128 | instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
129 |
130 | builder = InlineKeyboardBuilder()
131 | builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
132 | builder.row(InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}"))
133 | builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
134 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
135 |
136 | await edit_or_send_message(
137 | target_message=callback_query.message,
138 | text=instruction_message,
139 | reply_markup=builder.as_markup(),
140 | media_path=None,
141 | )
142 |
143 |
144 | @router.callback_query(F.data.startswith("connect_tv|"))
145 | async def process_connect_tv(callback_query: CallbackQuery):
146 | key_name = callback_query.data.split("|")[1]
147 |
148 | builder = InlineKeyboardBuilder()
149 | builder.row(InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}"))
150 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
151 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
152 |
153 | await edit_or_send_message(
154 | target_message=callback_query.message,
155 | text=CONNECT_TV_TEXT,
156 | reply_markup=builder.as_markup(),
157 | media_path=None,
158 | disable_web_page_preview=True,
159 | )
160 |
161 |
162 | @router.callback_query(F.data.startswith("continue_tv|"))
163 | async def process_continue_tv(callback_query: CallbackQuery, session: Any):
164 | key_name = callback_query.data.split("|")[1]
165 |
166 | record = await get_key_details(key_name, session)
167 | subscription_link = record["key"]
168 | message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
169 |
170 | builder = InlineKeyboardBuilder()
171 | builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
172 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
173 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
174 |
175 | await edit_or_send_message(
176 | target_message=callback_query.message,
177 | text=message_text,
178 | reply_markup=builder.as_markup(),
179 | media_path=None,
180 | )
181 |
--------------------------------------------------------------------------------
/handlers/keys/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .key_connect import router as connect_router
6 | from .key_freeze import router as freeze_router
7 | from .key_mode import router as key_mode_router
8 | from .key_renew import router as renew_router
9 | from .key_view import router as view_router
10 | from .keys import router as keys_router
11 |
12 |
13 | router = Router(name="keys_main_router")
14 |
15 | router.include_routers(keys_router, view_router, renew_router, freeze_router, connect_router, key_mode_router)
16 |
--------------------------------------------------------------------------------
/handlers/keys/key_connect.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from io import BytesIO
4 | from typing import Any
5 |
6 | import asyncpg
7 | import qrcode
8 |
9 | from aiogram import F, Router, types
10 | from aiogram.types import CallbackQuery, InlineKeyboardButton
11 | from aiogram.utils.keyboard import InlineKeyboardBuilder
12 |
13 | from config import CONNECT_ANDROID, CONNECT_IOS, DATABASE_URL, DOWNLOAD_ANDROID, DOWNLOAD_IOS, INSTRUCTIONS_BUTTON
14 | from handlers.buttons import (
15 | ANDROID,
16 | BACK,
17 | DOWNLOAD_ANDROID_BUTTON,
18 | DOWNLOAD_IOS_BUTTON,
19 | IMPORT_ANDROID,
20 | IMPORT_IOS,
21 | IPHONE,
22 | MAIN_MENU,
23 | MANUAL_INSTRUCTIONS,
24 | PC,
25 | TV,
26 | )
27 | from handlers.texts import (
28 | ANDROID_DESCRIPTION_TEMPLATE,
29 | CHOOSE_DEVICE_TEXT,
30 | IOS_DESCRIPTION_TEMPLATE,
31 | SUBSCRIPTION_DESCRIPTION,
32 | )
33 | from handlers.utils import edit_or_send_message
34 | from logger import logger
35 |
36 |
37 | router = Router()
38 |
39 |
40 | @router.callback_query(F.data.startswith("connect_device|"))
41 | async def handle_connect_device(callback_query: CallbackQuery):
42 | try:
43 | key_name = callback_query.data.split("|")[1]
44 |
45 | builder = InlineKeyboardBuilder()
46 | builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
47 | builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
48 | builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
49 | builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
50 | # builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
51 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
52 |
53 | await edit_or_send_message(
54 | target_message=callback_query.message,
55 | text=CHOOSE_DEVICE_TEXT,
56 | reply_markup=builder.as_markup(),
57 | media_path=None,
58 | )
59 | except Exception as e:
60 | await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
61 | logger.error(f"Ошибка в handle_connect_device: {e}")
62 |
63 |
64 | @router.callback_query(F.data.startswith("connect_phone|"))
65 | async def process_callback_connect_phone(callback_query: CallbackQuery):
66 | email = callback_query.data.split("|")[1]
67 |
68 | conn = None
69 | try:
70 | conn = await asyncpg.connect(DATABASE_URL)
71 | key_data = await conn.fetchrow(
72 | """
73 | SELECT key FROM keys WHERE email = $1
74 | """,
75 | email,
76 | )
77 | if not key_data:
78 | await callback_query.message.answer("❌ Ошибка: ключ не найден.")
79 | return
80 |
81 | key_link = key_data["key"]
82 |
83 | except Exception as e:
84 | logger.error(f"Ошибка при получении ключа для {email}: {e}")
85 | await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
86 | return
87 | finally:
88 | if conn:
89 | await conn.close()
90 |
91 | description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
92 |
93 | builder = InlineKeyboardBuilder()
94 | builder.row(
95 | InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
96 | InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
97 | )
98 | builder.row(
99 | InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
100 | InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
101 | )
102 | if INSTRUCTIONS_BUTTON:
103 | builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
104 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
105 |
106 | await edit_or_send_message(
107 | target_message=callback_query.message, text=description, reply_markup=builder.as_markup(), media_path=None
108 | )
109 |
110 |
111 | @router.callback_query(F.data.startswith("connect_ios|"))
112 | async def process_callback_connect_ios(callback_query: CallbackQuery):
113 | email = callback_query.data.split("|")[1]
114 |
115 | conn = None
116 | try:
117 | conn = await asyncpg.connect(DATABASE_URL)
118 | key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
119 | if not key_data:
120 | await callback_query.message.answer("❌ Ошибка: ключ не найден.")
121 | return
122 |
123 | key_link = key_data["key"]
124 |
125 | except Exception as e:
126 | logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
127 | await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
128 | return
129 | finally:
130 | if conn:
131 | await conn.close()
132 |
133 | description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
134 |
135 | builder = InlineKeyboardBuilder()
136 | builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
137 | builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
138 | if INSTRUCTIONS_BUTTON:
139 | builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
140 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
141 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
142 |
143 | await edit_or_send_message(
144 | target_message=callback_query.message,
145 | text=description,
146 | reply_markup=builder.as_markup(),
147 | media_path=None,
148 | )
149 |
150 |
151 | @router.callback_query(F.data.startswith("connect_android|"))
152 | async def process_callback_connect_android(callback_query: CallbackQuery):
153 | email = callback_query.data.split("|")[1]
154 |
155 | conn = None
156 | try:
157 | conn = await asyncpg.connect(DATABASE_URL)
158 | key_data = await conn.fetchrow("SELECT key FROM keys WHERE email = $1", email)
159 | if not key_data:
160 | await callback_query.message.answer("❌ Ошибка: ключ не найден.")
161 | return
162 |
163 | key_link = key_data["key"]
164 |
165 | except Exception as e:
166 | logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
167 | await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
168 | return
169 | finally:
170 | if conn:
171 | await conn.close()
172 |
173 | description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
174 |
175 | builder = InlineKeyboardBuilder()
176 | builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
177 | builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
178 | if INSTRUCTIONS_BUTTON:
179 | builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
180 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
181 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
182 |
183 | await edit_or_send_message(
184 | target_message=callback_query.message,
185 | text=description,
186 | reply_markup=builder.as_markup(),
187 | media_path=None,
188 | )
189 |
190 |
191 | @router.callback_query(F.data.startswith("show_qr|"))
192 | async def show_qr_code(callback_query: types.CallbackQuery, session: Any):
193 | try:
194 | key_name = callback_query.data.split("|")[1]
195 |
196 | record = await session.fetchrow("SELECT key, email FROM keys WHERE email = $1", key_name)
197 | if not record:
198 | await callback_query.message.answer("❌ Подписка не найдена.")
199 | return
200 |
201 | qr = qrcode.QRCode(version=1, box_size=10, border=4)
202 | qr.add_data(record["key"])
203 | qr.make(fit=True)
204 |
205 | img = qr.make_image(fill_color="black", back_color="white")
206 | buffer = BytesIO()
207 | img.save(buffer, format="PNG")
208 | buffer.seek(0)
209 |
210 | qr_path = f"/tmp/qrcode_{record['email']}.png"
211 | with open(qr_path, "wb") as f:
212 | f.write(buffer.read())
213 |
214 | builder = InlineKeyboardBuilder()
215 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
216 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
217 |
218 | await edit_or_send_message(
219 | target_message=callback_query.message,
220 | text="🔲 Ваш QR-код для подключения",
221 | reply_markup=builder.as_markup(),
222 | media_path=qr_path,
223 | )
224 |
225 | os.remove(qr_path)
226 |
227 | except Exception as e:
228 | logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
229 | await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
230 |
--------------------------------------------------------------------------------
/handlers/keys/key_freeze.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from typing import Any
4 |
5 | from aiogram import F, Router
6 | from aiogram.types import CallbackQuery, InlineKeyboardButton
7 | from aiogram.utils.keyboard import InlineKeyboardBuilder
8 |
9 | from config import (
10 | TOTAL_GB,
11 | )
12 | from database import (
13 | get_key_details,
14 | )
15 | from handlers.buttons import (
16 | APPLY,
17 | BACK,
18 | CANCEL,
19 | )
20 | from handlers.keys.key_utils import (
21 | renew_key_in_cluster,
22 | toggle_client_on_cluster,
23 | )
24 | from handlers.texts import (
25 | FREEZE_SUBSCRIPTION_CONFIRM_MSG,
26 | SUBSCRIPTION_FROZEN_MSG,
27 | SUBSCRIPTION_UNFROZEN_MSG,
28 | UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
29 | )
30 | from handlers.utils import edit_or_send_message, handle_error
31 |
32 |
33 | router = Router()
34 |
35 |
36 | @router.callback_query(F.data.startswith("unfreeze_subscription|"))
37 | async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
38 | key_name = callback_query.data.split("|")[1]
39 | confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
40 |
41 | builder = InlineKeyboardBuilder()
42 | builder.row(
43 | InlineKeyboardButton(
44 | text=APPLY,
45 | callback_data=f"unfreeze_subscription_confirm|{key_name}",
46 | ),
47 | InlineKeyboardButton(
48 | text=CANCEL,
49 | callback_data=f"view_key|{key_name}",
50 | ),
51 | )
52 |
53 | await edit_or_send_message(
54 | target_message=callback_query.message,
55 | text=confirm_text,
56 | reply_markup=builder.as_markup(),
57 | )
58 |
59 |
60 | @router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
61 | async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
62 | """
63 | Размораживает (включает) подписку.
64 | """
65 | tg_id = callback_query.message.chat.id
66 | key_name = callback_query.data.split("|")[1]
67 |
68 | try:
69 | record = await get_key_details(key_name, session)
70 | if not record:
71 | await callback_query.message.answer("Ключ не найден.")
72 | return
73 |
74 | email = record["email"]
75 | client_id = record["client_id"]
76 | cluster_id = record["server_id"]
77 |
78 | result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True)
79 | if result["status"] == "success":
80 | now_ms = int(time.time() * 1000)
81 | leftover = record["expiry_time"]
82 | if leftover < 0:
83 | leftover = 0
84 |
85 | new_expiry_time = now_ms + leftover
86 | await session.execute(
87 | """
88 | UPDATE keys
89 | SET expiry_time = $1,
90 | is_frozen = FALSE
91 | WHERE tg_id = $2
92 | AND client_id = $3
93 | """,
94 | new_expiry_time,
95 | record["tg_id"],
96 | client_id,
97 | )
98 | added_days = max(leftover / (1000 * 86400), 0.01)
99 | total_gb = int((added_days / 30) * TOTAL_GB * 1024**3)
100 |
101 | await renew_key_in_cluster(
102 | cluster_id=cluster_id,
103 | email=email,
104 | client_id=client_id,
105 | new_expiry_time=new_expiry_time,
106 | total_gb=total_gb,
107 | )
108 | text_ok = SUBSCRIPTION_UNFROZEN_MSG
109 | builder = InlineKeyboardBuilder()
110 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
111 | await edit_or_send_message(
112 | target_message=callback_query.message,
113 | text=text_ok,
114 | reply_markup=builder.as_markup(),
115 | )
116 | else:
117 | text_error = (
118 | f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
119 | )
120 | builder = InlineKeyboardBuilder()
121 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
122 | await edit_or_send_message(
123 | target_message=callback_query.message,
124 | text=text_error,
125 | reply_markup=builder.as_markup(),
126 | )
127 |
128 | except Exception as e:
129 | await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
130 |
131 |
132 | @router.callback_query(F.data.startswith("freeze_subscription|"))
133 | async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
134 | """
135 | Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
136 | """
137 | key_name = callback_query.data.split("|")[1]
138 |
139 | confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
140 |
141 | builder = InlineKeyboardBuilder()
142 | builder.row(
143 | InlineKeyboardButton(
144 | text=APPLY,
145 | callback_data=f"freeze_subscription_confirm|{key_name}",
146 | ),
147 | InlineKeyboardButton(
148 | text=CANCEL,
149 | callback_data=f"view_key|{key_name}",
150 | ),
151 | )
152 |
153 | await edit_or_send_message(
154 | target_message=callback_query.message,
155 | text=confirm_text,
156 | reply_markup=builder.as_markup(),
157 | )
158 |
159 |
160 | @router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
161 | async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
162 | """
163 | Замораживает (отключает) подписку.
164 | """
165 | tg_id = callback_query.message.chat.id
166 | key_name = callback_query.data.split("|")[1]
167 |
168 | try:
169 | record = await get_key_details(key_name, session)
170 | if not record:
171 | await callback_query.message.answer("Ключ не найден.")
172 | return
173 |
174 | email = record["email"]
175 | client_id = record["client_id"]
176 | cluster_id = record["server_id"]
177 |
178 | result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False)
179 |
180 | if result["status"] == "success":
181 | now_ms = int(time.time() * 1000)
182 | time_left = record["expiry_time"] - now_ms
183 | if time_left < 0:
184 | time_left = 0
185 |
186 | await session.execute(
187 | """
188 | UPDATE keys
189 | SET expiry_time = $1,
190 | is_frozen = TRUE
191 | WHERE tg_id = $2
192 | AND client_id = $3
193 | """,
194 | time_left,
195 | record["tg_id"],
196 | client_id,
197 | )
198 |
199 | text_ok = SUBSCRIPTION_FROZEN_MSG
200 | builder = InlineKeyboardBuilder()
201 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
202 | await edit_or_send_message(
203 | target_message=callback_query.message,
204 | text=text_ok,
205 | reply_markup=builder.as_markup(),
206 | )
207 | else:
208 | text_error = (
209 | f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
210 | )
211 | builder = InlineKeyboardBuilder()
212 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
213 | await edit_or_send_message(
214 | target_message=callback_query.message,
215 | text=text_error,
216 | reply_markup=builder.as_markup(),
217 | )
218 |
219 | except Exception as e:
220 | await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
221 |
--------------------------------------------------------------------------------
/handlers/keys/key_mode/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .key_cluster_mode import router as cluster_router
6 | from .key_country_mode import router as country_router
7 | from .key_create import router as create_router
8 |
9 |
10 | router = Router(name="key_mode_router")
11 |
12 | router.include_routers(
13 | create_router,
14 | cluster_router,
15 | country_router,
16 | )
17 |
--------------------------------------------------------------------------------
/handlers/keys/key_mode/key_cluster_mode.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from datetime import datetime
4 |
5 | import pytz
6 |
7 | from aiogram import Router
8 | from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, Message, WebAppInfo
9 | from aiogram.utils.keyboard import InlineKeyboardBuilder
10 |
11 | from bot import bot
12 | from config import (
13 | CONNECT_PHONE_BUTTON,
14 | RENEWAL_PRICES,
15 | SUPPORT_CHAT_URL,
16 | )
17 | from database import (
18 | get_key_details,
19 | get_trial,
20 | update_balance,
21 | update_trial,
22 | )
23 | from handlers.buttons import CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, PC_BUTTON, SUPPORT, TV_BUTTON
24 | from handlers.keys.key_utils import create_key_on_cluster
25 | from handlers.texts import (
26 | key_message_success,
27 | )
28 | from handlers.utils import (
29 | edit_or_send_message,
30 | generate_random_email,
31 | get_least_loaded_cluster,
32 | is_full_remnawave_cluster,
33 | )
34 | from logger import logger
35 |
36 |
37 | router = Router()
38 |
39 | moscow_tz = pytz.timezone("Europe/Moscow")
40 |
41 |
42 | async def key_cluster_mode(
43 | tg_id: int,
44 | expiry_time: datetime,
45 | state,
46 | session,
47 | message_or_query: Message | CallbackQuery | None = None,
48 | plan: int = None,
49 | ):
50 | target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
51 |
52 | while True:
53 | key_name = generate_random_email()
54 | existing_key = await get_key_details(key_name, session)
55 | if not existing_key:
56 | break
57 |
58 | client_id = str(uuid.uuid4())
59 | email = key_name.lower()
60 | expiry_timestamp = int(expiry_time.timestamp() * 1000)
61 |
62 | try:
63 | least_loaded_cluster = await get_least_loaded_cluster()
64 | await create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan, session)
65 | logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
66 |
67 | key_record = await get_key_details(email, session)
68 | if not key_record:
69 | raise ValueError(f"Ключ не найден после создания: {email}")
70 |
71 | public_link = key_record.get("key")
72 | remnawave_link = key_record.get("remnawave_link")
73 | final_link = public_link or remnawave_link or ""
74 |
75 | data = await state.get_data() if state else {}
76 |
77 | if data.get("is_trial"):
78 | trial_status = await get_trial(tg_id, session)
79 | if trial_status in [0, -1]:
80 | await update_trial(tg_id, 1, session)
81 |
82 | if data.get("plan_id"):
83 | plan_price = RENEWAL_PRICES.get(data["plan_id"])
84 | await update_balance(tg_id, -plan_price, session)
85 |
86 | logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
87 |
88 | except Exception as e:
89 | logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
90 | error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
91 | if target_message:
92 | await edit_or_send_message(
93 | target_message=target_message, text=error_message, reply_markup=None, media_path=None
94 | )
95 | else:
96 | await bot.send_message(chat_id=tg_id, text=error_message)
97 | return
98 |
99 | builder = InlineKeyboardBuilder()
100 |
101 | if await is_full_remnawave_cluster(least_loaded_cluster, session):
102 | builder.row(
103 | InlineKeyboardButton(
104 | text=CONNECT_DEVICE,
105 | web_app=WebAppInfo(url=final_link),
106 | )
107 | )
108 | elif CONNECT_PHONE_BUTTON:
109 | builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
110 | builder.row(
111 | InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
112 | InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
113 | )
114 | else:
115 | builder.row(
116 | InlineKeyboardButton(
117 | text=CONNECT_DEVICE,
118 | callback_data=f"connect_device|{key_name}",
119 | )
120 | )
121 |
122 | builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
123 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
124 |
125 | expiry_time_local = expiry_time.astimezone(moscow_tz)
126 | remaining_time = expiry_time_local - datetime.now(moscow_tz)
127 | days = remaining_time.days
128 | key_message_text = key_message_success(final_link, f"⏳ Осталось дней: {days} 📅")
129 |
130 | default_media_path = "img/pic.jpg"
131 |
132 | if target_message:
133 | await edit_or_send_message(
134 | target_message=target_message,
135 | text=key_message_text,
136 | reply_markup=builder.as_markup(),
137 | media_path=default_media_path,
138 | )
139 | else:
140 | photo = FSInputFile(default_media_path)
141 | await bot.send_photo(
142 | chat_id=tg_id,
143 | photo=photo,
144 | caption=key_message_text,
145 | reply_markup=builder.as_markup(),
146 | )
147 |
148 | if state:
149 | await state.clear()
150 |
--------------------------------------------------------------------------------
/handlers/keys/key_mode/key_create.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Any
3 |
4 | import pytz
5 |
6 | from aiogram import F, Router
7 | from aiogram.fsm.context import FSMContext
8 | from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
9 | from aiogram.utils.keyboard import InlineKeyboardBuilder
10 |
11 | from config import (
12 | NOTIFY_EXTRA_DAYS,
13 | RENEWAL_PRICES,
14 | TRIAL_TIME,
15 | TRIAL_TIME_DISABLE,
16 | USE_COUNTRY_SELECTION,
17 | USE_NEW_PAYMENT_FLOW,
18 | )
19 | from database import (
20 | add_user,
21 | check_user_exists,
22 | create_temporary_data,
23 | get_balance,
24 | get_trial,
25 | )
26 | from handlers.buttons import (
27 | MAIN_MENU,
28 | PAYMENT,
29 | )
30 | from handlers.payments.robokassa_pay import handle_custom_amount_input
31 | from handlers.payments.yookassa_pay import process_custom_amount_input
32 | from handlers.texts import (
33 | CREATING_CONNECTION_MSG,
34 | DISCOUNTS,
35 | INSUFFICIENT_FUNDS_MSG,
36 | SELECT_TARIFF_PLAN_MSG,
37 | )
38 | from handlers.utils import edit_or_send_message
39 | from logger import logger
40 |
41 | from .key_cluster_mode import key_cluster_mode
42 | from .key_country_mode import key_country_mode
43 |
44 |
45 | router = Router()
46 |
47 | moscow_tz = pytz.timezone("Europe/Moscow")
48 |
49 |
50 | class Form(FSMContext):
51 | waiting_for_server_selection = "waiting_for_server_selection"
52 |
53 |
54 | @router.callback_query(F.data == "create_key")
55 | async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
56 | tg_id = callback_query.message.chat.id
57 | await handle_key_creation(tg_id, state, session, callback_query)
58 |
59 |
60 | async def handle_key_creation(
61 | tg_id: int,
62 | state: FSMContext,
63 | session: Any,
64 | message_or_query: Message | CallbackQuery,
65 | ):
66 | """Создание ключа с учётом выбора тарифного плана."""
67 | current_time = datetime.now(moscow_tz)
68 |
69 | if not TRIAL_TIME_DISABLE:
70 | trial_status = await get_trial(tg_id, session)
71 | if trial_status in [0, -1]:
72 | extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
73 | expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
74 | logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
75 | await edit_or_send_message(
76 | target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
77 | text=CREATING_CONNECTION_MSG,
78 | reply_markup=None,
79 | )
80 | await state.update_data(is_trial=True)
81 | await create_key(tg_id, expiry_time, state, session, message_or_query)
82 | return
83 |
84 | builder = InlineKeyboardBuilder()
85 | for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
86 | discount_text = ""
87 | if DISCOUNTS and plan_id in DISCOUNTS:
88 | discount_percentage = DISCOUNTS[plan_id]
89 | discount_text = f" ({discount_percentage}% скидка)"
90 | if index == len(RENEWAL_PRICES) - 1:
91 | discount_text = f" ({discount_percentage}% 🔥)"
92 | builder.row(
93 | InlineKeyboardButton(
94 | text=f"📅 {plan_id} мес. - {price}₽{discount_text}",
95 | callback_data=f"select_plan_{plan_id}",
96 | )
97 | )
98 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
99 |
100 | if isinstance(message_or_query, CallbackQuery):
101 | target_message = message_or_query.message
102 | else:
103 | target_message = message_or_query
104 |
105 | await edit_or_send_message(
106 | target_message=target_message,
107 | text=SELECT_TARIFF_PLAN_MSG,
108 | reply_markup=builder.as_markup(),
109 | media_path=None,
110 | )
111 |
112 | await state.update_data(tg_id=tg_id)
113 | await state.set_state(Form.waiting_for_server_selection)
114 |
115 |
116 | @router.callback_query(F.data.startswith("select_plan_"))
117 | async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
118 | tg_id = callback_query.message.chat.id
119 | plan_id = callback_query.data.split("_")[-1]
120 | plan_price = RENEWAL_PRICES.get(plan_id)
121 | if plan_price is None:
122 | await callback_query.message.answer("🚫 Неверный тарифный план.")
123 | return
124 | duration_days = int(plan_id) * 30
125 | balance = await get_balance(tg_id)
126 | if balance < plan_price:
127 | required_amount = plan_price - balance
128 | await create_temporary_data(
129 | session,
130 | tg_id,
131 | "waiting_for_payment",
132 | {
133 | "plan_id": plan_id,
134 | "plan_price": plan_price,
135 | "duration_days": duration_days,
136 | "required_amount": required_amount,
137 | },
138 | )
139 | if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
140 | await process_custom_amount_input(callback_query, session)
141 | elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
142 | await handle_custom_amount_input(callback_query, session)
143 | else:
144 | builder = InlineKeyboardBuilder()
145 | builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
146 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
147 | await edit_or_send_message(
148 | target_message=callback_query.message,
149 | text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
150 | reply_markup=builder.as_markup(),
151 | media_path=None,
152 | )
153 | return
154 | builder = InlineKeyboardBuilder()
155 | builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
156 |
157 | await edit_or_send_message(
158 | target_message=callback_query.message,
159 | text=CREATING_CONNECTION_MSG,
160 | reply_markup=builder.as_markup(),
161 | )
162 |
163 | expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
164 | await state.update_data(plan_id=plan_id)
165 | await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
166 |
167 |
168 | async def create_key(
169 | tg_id: int,
170 | expiry_time,
171 | state,
172 | session,
173 | message_or_query=None,
174 | old_key_name: str = None,
175 | plan: int = None,
176 | ):
177 | """
178 | Универсальная точка входа для создания ключа.
179 | Делегирует выполнение в зависимости от выбранного режима (страна или кластер).
180 | Также отвечает за первичное подключение пользователя.
181 | """
182 | if not await check_user_exists(tg_id):
183 | from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
184 | if from_user:
185 | await add_user(
186 | tg_id=from_user.id,
187 | username=from_user.username,
188 | first_name=from_user.first_name,
189 | last_name=from_user.last_name,
190 | language_code=from_user.language_code,
191 | is_bot=from_user.is_bot,
192 | session=session,
193 | )
194 | logger.info(f"[User] Новый пользователь {tg_id} добавлен")
195 |
196 | if USE_COUNTRY_SELECTION:
197 | await key_country_mode(
198 | tg_id=tg_id,
199 | expiry_time=expiry_time,
200 | state=state,
201 | session=session,
202 | message_or_query=message_or_query,
203 | old_key_name=old_key_name,
204 | )
205 | else:
206 | await key_cluster_mode(
207 | tg_id=tg_id,
208 | expiry_time=expiry_time,
209 | state=state,
210 | session=session,
211 | message_or_query=message_or_query,
212 | plan=plan,
213 | )
214 |
--------------------------------------------------------------------------------
/handlers/keys/key_renew.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Any
3 |
4 | import asyncpg
5 |
6 | from aiogram import F, Router
7 | from aiogram.types import CallbackQuery, InlineKeyboardButton
8 | from aiogram.utils.keyboard import InlineKeyboardBuilder
9 |
10 | from bot import bot
11 | from config import (
12 | DATABASE_URL,
13 | RENEWAL_PRICES,
14 | TOTAL_GB,
15 | USE_COUNTRY_SELECTION,
16 | USE_NEW_PAYMENT_FLOW,
17 | )
18 | from database import (
19 | check_server_name_by_cluster,
20 | create_temporary_data,
21 | get_balance,
22 | get_key_by_server,
23 | get_key_details,
24 | update_balance,
25 | update_key_expiry,
26 | )
27 | from handlers.buttons import (
28 | BACK,
29 | MAIN_MENU,
30 | PAYMENT,
31 | )
32 | from handlers.keys.key_utils import (
33 | renew_key_in_cluster,
34 | )
35 | from handlers.payments.robokassa_pay import handle_custom_amount_input
36 | from handlers.payments.yookassa_pay import process_custom_amount_input
37 | from handlers.texts import (
38 | DISCOUNTS,
39 | INSUFFICIENT_FUNDS_RENEWAL_MSG,
40 | KEY_NOT_FOUND_MSG,
41 | PLAN_SELECTION_MSG,
42 | SUCCESS_RENEWAL_MSG,
43 | )
44 | from handlers.utils import edit_or_send_message, format_months
45 | from logger import logger
46 |
47 |
48 | router = Router()
49 |
50 |
51 | @router.callback_query(F.data.startswith("renew_key|"))
52 | async def process_callback_renew_key(callback_query: CallbackQuery, session: Any):
53 | tg_id = callback_query.message.chat.id
54 | key_name = callback_query.data.split("|")[1]
55 | try:
56 | record = await get_key_details(key_name, session)
57 | if record:
58 | client_id = record["client_id"]
59 | expiry_time = record["expiry_time"]
60 |
61 | builder = InlineKeyboardBuilder()
62 |
63 | for plan_id, price in RENEWAL_PRICES.items():
64 | months = int(plan_id)
65 |
66 | discount = DISCOUNTS.get(plan_id, 0) if isinstance(DISCOUNTS, dict) else 0
67 |
68 | button_text = f"📅 {format_months(months)} ({price} руб.)"
69 | if discount > 0:
70 | button_text += f" {discount}% скидка"
71 |
72 | builder.row(
73 | InlineKeyboardButton(
74 | text=button_text,
75 | callback_data=f"renew_plan|{months}|{client_id}",
76 | )
77 | )
78 |
79 | builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}"))
80 |
81 | balance = await get_balance(tg_id)
82 |
83 | response_message = PLAN_SELECTION_MSG.format(
84 | balance=balance,
85 | expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
86 | )
87 |
88 | await edit_or_send_message(
89 | target_message=callback_query.message,
90 | text=response_message,
91 | reply_markup=builder.as_markup(),
92 | media_path=None,
93 | )
94 | else:
95 | await callback_query.message.answer("Ключ не найден.")
96 | except Exception as e:
97 | logger.error(f"Ошибка в process_callback_renew_key: {e}")
98 | await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
99 |
100 |
101 | @router.callback_query(F.data.startswith("renew_plan|"))
102 | async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
103 | tg_id = callback_query.message.chat.id
104 | plan, client_id = callback_query.data.split("|")[1], callback_query.data.split("|")[2]
105 | days_to_extend = 30 * int(plan)
106 |
107 | total_gb = int((int(plan) or 1) * TOTAL_GB * 1024**3)
108 |
109 | try:
110 | record = await get_key_by_server(tg_id, client_id, session)
111 |
112 | if record:
113 | email = record["email"]
114 | expiry_time = record["expiry_time"]
115 | current_time = datetime.utcnow().timestamp() * 1000
116 |
117 | if expiry_time <= current_time:
118 | new_expiry_time = int(current_time + timedelta(days=days_to_extend).total_seconds() * 1000)
119 | else:
120 | new_expiry_time = int(expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000)
121 |
122 | cost = RENEWAL_PRICES.get(plan)
123 | if cost is None:
124 | await callback_query.message.answer("❌ Неверный тарифный план.")
125 | return
126 |
127 | balance = await get_balance(tg_id)
128 |
129 | if balance < cost:
130 | required_amount = cost - balance
131 |
132 | logger.info(
133 | f"[RENEW] Пользователю {tg_id} не хватает {required_amount}₽. Запуск доплаты через {USE_NEW_PAYMENT_FLOW}"
134 | )
135 |
136 | await create_temporary_data(
137 | session,
138 | tg_id,
139 | "waiting_for_renewal_payment",
140 | {
141 | "plan": plan,
142 | "client_id": client_id,
143 | "cost": cost,
144 | "required_amount": required_amount,
145 | "new_expiry_time": new_expiry_time,
146 | "total_gb": total_gb,
147 | "email": email,
148 | },
149 | )
150 |
151 | if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
152 | logger.info(f"[RENEW] Запуск оплаты через Юкассу для пользователя {tg_id}")
153 | await process_custom_amount_input(callback_query, session)
154 | elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
155 | logger.info(f"[RENEW] Запуск оплаты через Робокассу для пользователя {tg_id}")
156 | await handle_custom_amount_input(callback_query, session)
157 | else:
158 | logger.info(f"[RENEW] Отправка сообщения о доплате пользователю {tg_id}")
159 | builder = InlineKeyboardBuilder()
160 | builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
161 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
162 |
163 | await edit_or_send_message(
164 | target_message=callback_query.message,
165 | text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
166 | reply_markup=builder.as_markup(),
167 | media_path=None,
168 | )
169 | return
170 |
171 | logger.info(f"[RENEW] Средств достаточно. Продление ключа для пользователя {tg_id}")
172 | await complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan)
173 |
174 | else:
175 | await callback_query.message.answer(KEY_NOT_FOUND_MSG)
176 | logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
177 | except Exception as e:
178 | logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
179 |
180 |
181 | async def complete_key_renewal(tg_id, client_id, email, new_expiry_time, total_gb, cost, callback_query, plan):
182 | logger.info(f"[Info] Продление ключа {client_id} на {plan} мес. (Start)")
183 |
184 | builder = InlineKeyboardBuilder()
185 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
186 | response_message = SUCCESS_RENEWAL_MSG.format(months_formatted=format_months(int(plan)))
187 |
188 | if callback_query:
189 | try:
190 | await edit_or_send_message(
191 | target_message=callback_query.message,
192 | text=response_message,
193 | reply_markup=builder.as_markup(),
194 | media_path=None,
195 | )
196 | except Exception as e:
197 | logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
198 | await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
199 | else:
200 | await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
201 |
202 | conn = await asyncpg.connect(DATABASE_URL)
203 | key_info = await get_key_details(email, conn)
204 | if not key_info:
205 | logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
206 | await conn.close()
207 | return
208 |
209 | server_id = key_info["server_id"]
210 |
211 | if USE_COUNTRY_SELECTION:
212 | cluster_info = await check_server_name_by_cluster(server_id, conn)
213 | if not cluster_info:
214 | logger.error(f"[Error] Сервер {server_id} не найден в таблице servers.")
215 | await conn.close()
216 | return
217 | cluster_id = cluster_info["cluster_name"]
218 | else:
219 | cluster_id = server_id
220 |
221 | await renew_key_in_cluster(cluster_id, email, client_id, new_expiry_time, total_gb)
222 | await update_key_expiry(client_id, new_expiry_time, conn)
223 | await update_balance(tg_id, -cost, conn)
224 | await conn.close()
225 |
226 | logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
227 |
--------------------------------------------------------------------------------
/handlers/keys/keys.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from typing import Any
4 |
5 | from aiogram import F, Router, types
6 | from aiogram.exceptions import TelegramBadRequest
7 | from aiogram.types import CallbackQuery
8 |
9 | from database import (
10 | delete_key,
11 | get_key_details,
12 | get_servers,
13 | )
14 | from handlers.buttons import (
15 | APPLY,
16 | BACK,
17 | CANCEL,
18 | )
19 | from handlers.keys.key_utils import (
20 | delete_key_from_cluster,
21 | update_subscription,
22 | )
23 | from handlers.keys.key_view import process_callback_view_key
24 | from handlers.texts import (
25 | DELETE_KEY_CONFIRM_MSG,
26 | KEY_DELETED_MSG_SIMPLE,
27 | )
28 | from handlers.utils import edit_or_send_message, handle_error
29 | from logger import logger
30 |
31 |
32 | router = Router()
33 |
34 |
35 | @router.callback_query(F.data.startswith("update_subscription|"))
36 | async def process_callback_update_subscription(callback_query: CallbackQuery, session: Any):
37 | tg_id = callback_query.message.chat.id
38 | email = callback_query.data.split("|")[1]
39 |
40 | try:
41 | try:
42 | await callback_query.message.delete()
43 | except TelegramBadRequest as e:
44 | if "message can't be deleted" not in str(e):
45 | raise
46 |
47 | await update_subscription(tg_id, email, session)
48 | await process_callback_view_key(callback_query, session)
49 | except Exception as e:
50 | logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
51 | await handle_error(tg_id, callback_query, f"Ошибка при обновлении подписки: {e}")
52 |
53 |
54 | @router.callback_query(F.data.startswith("delete_key|"))
55 | async def process_callback_delete_key(callback_query: CallbackQuery):
56 | client_id = callback_query.data.split("|")[1]
57 | try:
58 | confirmation_keyboard = types.InlineKeyboardMarkup(
59 | inline_keyboard=[
60 | [
61 | types.InlineKeyboardButton(
62 | text=APPLY,
63 | callback_data=f"confirm_delete|{client_id}",
64 | )
65 | ],
66 | [types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
67 | ]
68 | )
69 |
70 | if callback_query.message.caption:
71 | await callback_query.message.edit_caption(
72 | caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
73 | )
74 | else:
75 | await callback_query.message.edit_text(text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard)
76 |
77 | except Exception as e:
78 | logger.error(f"Ошибка при обработке запроса на удаление ключа {client_id}: {e}")
79 |
80 |
81 | @router.callback_query(F.data.startswith("confirm_delete|"))
82 | async def process_callback_confirm_delete(callback_query: CallbackQuery, session: Any):
83 | email = callback_query.data.split("|")[1]
84 | try:
85 | record = await get_key_details(email, session)
86 | if record:
87 | client_id = record["client_id"]
88 | response_message = KEY_DELETED_MSG_SIMPLE
89 | back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
90 | keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
91 |
92 | await delete_key(client_id, session)
93 |
94 | await edit_or_send_message(
95 | target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
96 | )
97 |
98 | servers = await get_servers(session)
99 |
100 | async def delete_key_from_servers():
101 | try:
102 | tasks = []
103 | for cluster_id, _cluster in servers.items():
104 | tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
105 | await asyncio.gather(*tasks, return_exceptions=True)
106 | except Exception as e:
107 | logger.error(f"Ошибка при удалении ключа {client_id}: {e}")
108 |
109 | asyncio.create_task(delete_key_from_servers())
110 |
111 | await delete_key(client_id, session)
112 | else:
113 | response_message = "Ключ не найден или уже удален."
114 | back_button = types.InlineKeyboardButton(text=BACK, callback_data="view_keys")
115 | keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
116 | await edit_or_send_message(
117 | target_message=callback_query.message, text=response_message, reply_markup=keyboard, media_path=None
118 | )
119 | except Exception as e:
120 | logger.error(e)
121 |
--------------------------------------------------------------------------------
/handlers/notifications/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 |
5 | from .general_notifications import router as general_notifications_router
6 | from .special_notifications import router as special_notifications_router
7 |
8 |
9 | router = Router(name="notifications_main_router")
10 |
11 | router.include_routers(general_notifications_router, special_notifications_router)
12 |
--------------------------------------------------------------------------------
/handlers/notifications/notify_kb.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import InlineKeyboardMarkup
2 |
3 | from handlers.buttons import MAIN_MENU, RENEW_KEY
4 |
5 |
6 | def build_notification_kb(email: str) -> InlineKeyboardMarkup:
7 | """
8 | Формирует inline-клавиатуру для уведомлений.
9 | Кнопки: "🔄 Продлить VPN" (callback_data содержит email) и "👤 Личный кабинет".
10 | """
11 | from aiogram.utils.keyboard import InlineKeyboardBuilder
12 |
13 | builder = InlineKeyboardBuilder()
14 | builder.button(text=RENEW_KEY, callback_data=f"renew_key|{email}")
15 | builder.button(text=MAIN_MENU, callback_data="profile")
16 | builder.adjust(1)
17 | return builder.as_markup()
18 |
19 |
20 | def build_notification_expired_kb() -> InlineKeyboardMarkup:
21 | """
22 | Формирует inline-клавиатуру для уведомлений после удаления или продления.
23 | Кнопка: "👤 Личный кабинет"
24 | """
25 | from aiogram.utils.keyboard import InlineKeyboardBuilder
26 |
27 | builder = InlineKeyboardBuilder()
28 | builder.button(text=MAIN_MENU, callback_data="profile")
29 | return builder.as_markup()
30 |
--------------------------------------------------------------------------------
/handlers/notifications/notify_utils.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import os
3 |
4 | import aiofiles
5 | import asyncpg
6 |
7 | from aiogram import Bot
8 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
9 | from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
10 |
11 | from database import create_blocked_user
12 | from logger import logger
13 |
14 |
15 | async def send_messages_with_limit(
16 | bot: Bot,
17 | messages: list[dict],
18 | conn: asyncpg.Connection = None,
19 | source_file: str = None,
20 | messages_per_second: int = 25,
21 | ):
22 | """
23 | Отправляет сообщения с ограничением по количеству сообщений в секунду.
24 | Возвращает список результатов отправки (True для успеха, False для ошибки).
25 | """
26 | batch_size = messages_per_second
27 | results = []
28 | for i in range(0, len(messages), batch_size):
29 | batch = messages[i : i + batch_size]
30 | tasks = []
31 | for msg in batch:
32 | tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
33 | batch_results = await asyncio.gather(*tasks, return_exceptions=True)
34 | processed_results = []
35 | for msg, result in zip(batch, batch_results, strict=False):
36 | tg_id = msg["tg_id"]
37 | if isinstance(result, bool) and result:
38 | processed_results.append(True)
39 | elif isinstance(result, TelegramForbiddenError):
40 | logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
41 | if source_file == "special_notifications" and conn:
42 | try:
43 | await create_blocked_user(tg_id, conn)
44 | logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
45 | except Exception:
46 | pass
47 | processed_results.append(False)
48 | elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
49 | logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
50 | if source_file == "special_notifications" and conn:
51 | try:
52 | await create_blocked_user(tg_id, conn)
53 | logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
54 | except Exception:
55 | pass
56 | processed_results.append(False)
57 | else:
58 | logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
59 | if source_file == "special_notifications" and conn:
60 | try:
61 | await create_blocked_user(tg_id, conn)
62 | logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
63 | except Exception:
64 | pass
65 | processed_results.append(False)
66 | results.extend(processed_results)
67 | await asyncio.sleep(1.0)
68 | return results
69 |
70 |
71 | def rate_limited_send(func):
72 | async def wrapper(*args, **kwargs):
73 | while True:
74 | try:
75 | return await func(*args, **kwargs)
76 | except TelegramRetryAfter as e:
77 | retry_in = int(e.retry_after) + 1
78 | logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек.")
79 | await asyncio.sleep(retry_in)
80 | except TelegramForbiddenError:
81 | tg_id = kwargs.get("tg_id") or args[1]
82 | logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
83 | return False
84 | except TelegramBadRequest:
85 | tg_id = kwargs.get("tg_id") or args[1]
86 | logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
87 | return False
88 | except Exception as e:
89 | tg_id = kwargs.get("tg_id") or args[1]
90 | logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
91 | return False
92 |
93 | return wrapper
94 |
95 |
96 | async def send_notification(
97 | bot: Bot,
98 | tg_id: int,
99 | image_filename: str | None,
100 | caption: str,
101 | keyboard: InlineKeyboardMarkup | None = None,
102 | ) -> bool:
103 | """
104 | Отправляет уведомление пользователю.
105 | """
106 | if image_filename is None:
107 | return await _send_text_notification(bot, tg_id, caption, keyboard)
108 |
109 | photo_path = os.path.join("img", image_filename)
110 | if os.path.isfile(photo_path):
111 | return await _send_photo_notification(bot, tg_id, photo_path, image_filename, caption, keyboard)
112 | else:
113 | logger.warning(f"Файл с изображением не найден: {photo_path}")
114 | return await _send_text_notification(bot, tg_id, caption, keyboard)
115 |
116 |
117 | @rate_limited_send
118 | async def _send_photo_notification(
119 | bot: Bot,
120 | tg_id: int,
121 | photo_path: str,
122 | image_filename: str,
123 | caption: str,
124 | keyboard: InlineKeyboardMarkup | None = None,
125 | ) -> bool:
126 | """Отправляет уведомление с изображением."""
127 | try:
128 | async with aiofiles.open(photo_path, "rb") as image_file:
129 | image_data = await image_file.read()
130 | buffered_photo = BufferedInputFile(image_data, filename=image_filename)
131 | await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
132 | return True
133 | except (TelegramForbiddenError, TelegramBadRequest):
134 | return False
135 | except Exception as e:
136 | logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
137 | return await _send_text_notification(bot, tg_id, caption, keyboard)
138 |
139 |
140 | @rate_limited_send
141 | async def _send_text_notification(
142 | bot: Bot,
143 | tg_id: int,
144 | caption: str,
145 | keyboard: InlineKeyboardMarkup | None = None,
146 | ) -> bool:
147 | """Отправляет текстовое уведомление."""
148 | try:
149 | await bot.send_message(tg_id, caption, reply_markup=keyboard)
150 | return True
151 | except (TelegramForbiddenError, TelegramBadRequest):
152 | return False
153 | except Exception as e:
154 | logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
155 | return False
156 |
--------------------------------------------------------------------------------
/handlers/notifications/special_notifications.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from datetime import datetime, timedelta
4 |
5 | import asyncpg
6 | import pytz
7 |
8 | from aiogram import Bot, Router, types
9 | from aiogram.utils.keyboard import InlineKeyboardBuilder
10 |
11 | from config import (
12 | NOTIFY_EXTRA_DAYS,
13 | NOTIFY_INACTIVE,
14 | NOTIFY_INACTIVE_TRAFFIC,
15 | SUPPORT_CHAT_URL,
16 | TRIAL_TIME,
17 | )
18 | from database import add_notification, check_notifications_bulk, create_blocked_user
19 | from handlers.buttons import MAIN_MENU
20 | from handlers.keys.key_utils import get_user_traffic
21 | from handlers.texts import (
22 | TRIAL_INACTIVE_BONUS_MSG,
23 | TRIAL_INACTIVE_FIRST_MSG,
24 | ZERO_TRAFFIC_MSG,
25 | )
26 | from handlers.utils import format_days
27 | from logger import logger
28 |
29 | from .notify_utils import send_messages_with_limit, send_notification
30 |
31 |
32 | router = Router()
33 | moscow_tz = pytz.timezone("Europe/Moscow")
34 |
35 |
36 | async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
37 | """
38 | Проверяет пользователей, не активировавших пробный период, и отправляет им напоминания.
39 | Первое уведомление — стандартное.
40 | Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
41 | """
42 | logger.info("Проверка пользователей, не активировавших пробный период...")
43 | users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
44 | logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
45 | messages = []
46 | for user in users:
47 | tg_id = user["tg_id"]
48 | username = user["username"]
49 | first_name = user["first_name"]
50 | last_name = user["last_name"]
51 | display_name = username or first_name or last_name or "Пользователь"
52 | builder = InlineKeyboardBuilder()
53 | builder.row(
54 | types.InlineKeyboardButton(
55 | text="🚀 Активировать пробный период",
56 | callback_data="create_key",
57 | )
58 | )
59 | builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
60 | keyboard = builder.as_markup()
61 | trial_extended = user["last_notification_time"] is not None
62 | if trial_extended:
63 | total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
64 | message = TRIAL_INACTIVE_BONUS_MSG.format(
65 | display_name=display_name,
66 | extra_days_formatted=format_days(NOTIFY_EXTRA_DAYS),
67 | total_days_formatted=format_days(total_days),
68 | )
69 | await conn.execute("UPDATE users SET trial = -1 WHERE tg_id = $1", tg_id)
70 | else:
71 | message = TRIAL_INACTIVE_FIRST_MSG.format(
72 | display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
73 | )
74 | messages.append({
75 | "tg_id": tg_id,
76 | "text": message,
77 | "keyboard": keyboard,
78 | "notification_id": "inactive_trial",
79 | })
80 | if messages:
81 | results = await send_messages_with_limit(
82 | bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
83 | )
84 | sent_count = 0
85 | for msg, result in zip(messages, results, strict=False):
86 | tg_id = msg["tg_id"]
87 | if result:
88 | await add_notification(tg_id, msg["notification_id"], session=conn)
89 | sent_count += 1
90 | logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
91 | else:
92 | logger.warning(f"📩 Не удалось отправить уведомление неактивному пользователю {tg_id}.")
93 | logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
94 | logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
95 |
96 |
97 | async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
98 | """
99 | Проверяет трафик пользователей, у которых ещё не отправлялось уведомление о нулевом трафике.
100 | Если трафик 0 ГБ и прошло более 2 часов с момента создания ключа, отправляет уведомление,
101 | но исключает пользователей, у которых подписка недавно продлилась.
102 | """
103 | logger.info("Проверка пользователей с нулевым трафиком...")
104 | current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
105 | messages = []
106 |
107 | for key in keys:
108 | tg_id = key.get("tg_id")
109 | email = key.get("email")
110 | created_at = key.get("created_at")
111 | client_id = key.get("client_id")
112 | expiry_time = key.get("expiry_time")
113 | notified = key.get("notified")
114 |
115 | if created_at is None:
116 | logger.warning(f"Для {email} нет значения created_at. Пропускаем.")
117 | continue
118 |
119 | if notified is True:
120 | continue
121 |
122 | created_at_dt = pytz.utc.localize(datetime.fromtimestamp(created_at / 1000)).astimezone(moscow_tz)
123 | created_at_plus_2 = created_at_dt + timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
124 |
125 | if expiry_time:
126 | expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
127 | renewal_threshold = expiry_dt - timedelta(days=30)
128 | renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
129 | if renewal_recent:
130 | continue
131 |
132 | if current_dt < created_at_plus_2:
133 | continue
134 |
135 | try:
136 | traffic_data = await get_user_traffic(conn, tg_id, email)
137 | except Exception as e:
138 | logger.error(f"Ошибка получения трафика для {email}: {e}")
139 | continue
140 |
141 | if traffic_data.get("status") != "success":
142 | logger.warning(f"⚠ Ошибка при получении трафика для {email}: {traffic_data.get('message')}")
143 | continue
144 |
145 | total_traffic = sum(
146 | value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
147 | )
148 |
149 | try:
150 | await conn.execute("UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id)
151 | except Exception as e:
152 | logger.error(f"Ошибка обновления notified для пользователя {tg_id} (client_id: {client_id}): {e}")
153 | continue
154 |
155 | if total_traffic == 0:
156 | logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
157 | builder = InlineKeyboardBuilder()
158 | builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
159 | builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
160 | keyboard = builder.as_markup()
161 | message = ZERO_TRAFFIC_MSG.format(email=email)
162 | messages.append({
163 | "tg_id": tg_id,
164 | "text": message,
165 | "keyboard": keyboard,
166 | "client_id": client_id,
167 | })
168 |
169 | if messages:
170 | results = await send_messages_with_limit(
171 | bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
172 | )
173 | sent_count = 0
174 | for msg, result in zip(messages, results, strict=False):
175 | tg_id = msg["tg_id"]
176 | if result:
177 | sent_count += 1
178 | logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
179 | else:
180 | logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
181 | logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
182 |
183 | logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
184 |
--------------------------------------------------------------------------------
/handlers/pay.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from typing import Any
4 |
5 | from aiogram import F, Router
6 | from aiogram.types import CallbackQuery, InlineKeyboardButton
7 | from aiogram.utils.keyboard import InlineKeyboardBuilder
8 |
9 | from config import (
10 | CRYPTO_BOT_ENABLE,
11 | DONATIONS_ENABLE,
12 | ROBOKASSA_ENABLE,
13 | STARS_ENABLE,
14 | YOOKASSA_ENABLE,
15 | YOOMONEY_ENABLE,
16 | )
17 | from database import get_last_payments
18 | from handlers.buttons import (
19 | BALANCE_HISTORY,
20 | COUPON,
21 | CRYPTOBOT,
22 | MAIN_MENU,
23 | PAYMENT,
24 | ROBOKASSA,
25 | STARS,
26 | YOOKASSA,
27 | YOOMONEY,
28 | )
29 | from handlers.texts import BALANCE_HISTORY_HEADER, BALANCE_MANAGEMENT_TEXT, PAYMENT_METHODS_MSG
30 |
31 | from .utils import edit_or_send_message
32 |
33 |
34 | router = Router()
35 |
36 |
37 | @router.callback_query(F.data == "pay")
38 | async def handle_pay(callback_query: CallbackQuery):
39 | builder = InlineKeyboardBuilder()
40 |
41 | if YOOKASSA_ENABLE:
42 | builder.row(
43 | InlineKeyboardButton(
44 | text=YOOKASSA,
45 | callback_data="pay_yookassa",
46 | )
47 | )
48 | if YOOMONEY_ENABLE:
49 | builder.row(
50 | InlineKeyboardButton(
51 | text=YOOMONEY,
52 | callback_data="pay_yoomoney",
53 | )
54 | )
55 | if CRYPTO_BOT_ENABLE:
56 | builder.row(
57 | InlineKeyboardButton(
58 | text=CRYPTOBOT,
59 | callback_data="pay_cryptobot",
60 | )
61 | )
62 | if STARS_ENABLE:
63 | builder.row(
64 | InlineKeyboardButton(
65 | text=STARS,
66 | callback_data="pay_stars",
67 | )
68 | )
69 | if ROBOKASSA_ENABLE:
70 | builder.row(
71 | InlineKeyboardButton(
72 | text=ROBOKASSA,
73 | callback_data="pay_robokassa",
74 | )
75 | )
76 | if DONATIONS_ENABLE:
77 | builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
78 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
79 |
80 | await edit_or_send_message(
81 | target_message=callback_query.message,
82 | text=PAYMENT_METHODS_MSG,
83 | reply_markup=builder.as_markup(),
84 | media_path=None,
85 | disable_web_page_preview=False,
86 | )
87 |
88 |
89 | @router.callback_query(F.data == "balance")
90 | async def balance_handler(callback_query: CallbackQuery, session: Any):
91 | result = await session.fetchrow(
92 | "SELECT balance FROM users WHERE tg_id = $1",
93 | callback_query.from_user.id,
94 | )
95 | balance = result["balance"] if result else 0.0
96 | balance = int(balance)
97 |
98 | builder = InlineKeyboardBuilder()
99 | builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
100 | builder.row(InlineKeyboardButton(text=BALANCE_HISTORY, callback_data="balance_history"))
101 | builder.row(InlineKeyboardButton(text=COUPON, callback_data="activate_coupon"))
102 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
103 |
104 | text = BALANCE_MANAGEMENT_TEXT.format(balance=balance)
105 | image_path = os.path.join("img", "pic.jpg")
106 |
107 | await edit_or_send_message(
108 | target_message=callback_query.message,
109 | text=text,
110 | reply_markup=builder.as_markup(),
111 | media_path=image_path,
112 | disable_web_page_preview=False,
113 | )
114 |
115 |
116 | @router.callback_query(F.data == "balance_history")
117 | async def balance_history_handler(callback_query: CallbackQuery, session: Any):
118 | builder = InlineKeyboardBuilder()
119 | builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
120 | builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
121 |
122 | records = await get_last_payments(callback_query.from_user.id, session)
123 |
124 | if records:
125 | history_text = BALANCE_HISTORY_HEADER
126 | for record in records:
127 | amount = record["amount"]
128 | payment_system = record["payment_system"]
129 | status = record["status"]
130 | date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
131 | history_text += (
132 | f"Сумма: {amount}₽\n"
133 | f"Способ оплаты: {payment_system}\n"
134 | f"Статус: {status}\n"
135 | f"Дата: {date}\n\n"
136 | )
137 | else:
138 | history_text = "❌ У вас пока нет операций с балансом."
139 |
140 | await edit_or_send_message(
141 | target_message=callback_query.message,
142 | text=history_text,
143 | reply_markup=builder.as_markup(),
144 | media_path=None,
145 | disable_web_page_preview=False,
146 | )
147 |
--------------------------------------------------------------------------------
/handlers/payments/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = ("router",)
2 |
3 | from aiogram import Router
4 | from config import (
5 | CRYPTO_BOT_ENABLE,
6 | ROBOKASSA_ENABLE,
7 | STARS_ENABLE,
8 | YOOKASSA_ENABLE,
9 | YOOMONEY_ENABLE,
10 | )
11 |
12 | from .cryprobot_pay import router as cryprobot_router
13 | from .gift import router as gift_router
14 | from .robokassa_pay import router as robokassa_router
15 | from .stars_pay import router as stars_router
16 | from .yookassa_pay import router as yookassa_router
17 | from .yoomoney_pay import router as yoomoney_router
18 |
19 | router = Router(name="payments_main_router")
20 |
21 | if YOOKASSA_ENABLE:
22 | router.include_router(yookassa_router)
23 | if YOOMONEY_ENABLE:
24 | router.include_router(yoomoney_router)
25 | if ROBOKASSA_ENABLE:
26 | router.include_router(robokassa_router)
27 | if CRYPTO_BOT_ENABLE:
28 | router.include_router(cryprobot_router)
29 | if STARS_ENABLE:
30 | router.include_router(stars_router)
31 |
32 | router.include_router(gift_router)
33 |
--------------------------------------------------------------------------------
/handlers/payments/cryprobot_pay.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/cryprobot_pay.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/gift.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/payments/stars_pay.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/stars_pay.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/utils.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/payments/yookassa_pay.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/yookassa_pay.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/payments/yoomoney_pay.cpython-312-x86_64-linux-gnu.so:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/handlers/payments/yoomoney_pay.cpython-312-x86_64-linux-gnu.so
--------------------------------------------------------------------------------
/handlers/profile.py:
--------------------------------------------------------------------------------
1 | import html
2 | import os
3 |
4 | import asyncpg
5 |
6 | from aiogram import F, Router
7 | from aiogram.fsm.context import FSMContext
8 | from aiogram.types import (
9 | CallbackQuery,
10 | InlineKeyboardButton,
11 | Message,
12 | )
13 | from aiogram.utils.keyboard import InlineKeyboardBuilder
14 |
15 | from config import (
16 | DATABASE_URL,
17 | GIFT_BUTTON,
18 | INSTRUCTIONS_BUTTON,
19 | NEWS_MESSAGE,
20 | REFERRAL_BUTTON,
21 | SHOW_START_MENU_ONCE,
22 | )
23 | from database import get_balance, get_key_count, get_trial
24 | from handlers.buttons import (
25 | ABOUT_VPN,
26 | ADD_SUB,
27 | BACK,
28 | BALANCE,
29 | GIFTS,
30 | INSTRUCTIONS,
31 | INVITE,
32 | MY_SUBS,
33 | TRIAL_SUB,
34 | )
35 | from logger import logger
36 |
37 | from .admin.panel.keyboard import AdminPanelCallback
38 | from .texts import profile_message_send
39 | from .utils import edit_or_send_message
40 |
41 |
42 | router = Router()
43 |
44 |
45 | @router.callback_query(F.data == "profile")
46 | @router.message(F.text == "/profile")
47 | async def process_callback_view_profile(
48 | callback_query_or_message: Message | CallbackQuery,
49 | state: FSMContext,
50 | admin: bool,
51 | ):
52 | if isinstance(callback_query_or_message, CallbackQuery):
53 | chat = callback_query_or_message.message.chat
54 | from_user = callback_query_or_message.from_user
55 | chat_id = chat.id
56 | target_message = callback_query_or_message.message
57 | else:
58 | chat = callback_query_or_message.chat
59 | from_user = callback_query_or_message.from_user
60 | chat_id = chat.id
61 | target_message = callback_query_or_message
62 |
63 | user = chat if chat.type == "private" else from_user
64 |
65 | if getattr(user, "full_name", None):
66 | username = html.escape(user.full_name)
67 | elif getattr(user, "first_name", None):
68 | username = html.escape(user.first_name)
69 | elif getattr(user, "username", None):
70 | username = "@" + html.escape(user.username)
71 | else:
72 | username = "Пользователь"
73 |
74 | image_path = os.path.join("img", "profile.jpg")
75 | logger.info(f"Переход в профиль. Используется изображение: {image_path}")
76 |
77 | key_count = await get_key_count(chat_id)
78 | balance = await get_balance(chat_id) or 0
79 |
80 | conn = await asyncpg.connect(DATABASE_URL)
81 | try:
82 | trial_status = await get_trial(chat_id, conn)
83 |
84 | profile_message = profile_message_send(username, chat_id, int(balance), key_count)
85 | if key_count == 0:
86 | profile_message += (
87 | "\n
🔧 Нажмите кнопку ➕ Подписка, чтобы настроить VPN-подключение" 88 | ) 89 | else: 90 | profile_message += f"\n
{NEWS_MESSAGE}" 91 | 92 | builder = InlineKeyboardBuilder() 93 | if key_count > 0: 94 | builder.row(InlineKeyboardButton(text=MY_SUBS, callback_data="view_keys")) 95 | elif trial_status == 0: 96 | builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key")) 97 | else: 98 | builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key")) 99 | builder.row(InlineKeyboardButton(text=BALANCE, callback_data="balance")) 100 | 101 | row_buttons = [] 102 | if REFERRAL_BUTTON: 103 | row_buttons.append(InlineKeyboardButton(text=INVITE, callback_data="invite")) 104 | if GIFT_BUTTON: 105 | row_buttons.append(InlineKeyboardButton(text=GIFTS, callback_data="gifts")) 106 | if row_buttons: 107 | builder.row(*row_buttons) 108 | 109 | if INSTRUCTIONS_BUTTON: 110 | builder.row(InlineKeyboardButton(text=INSTRUCTIONS, callback_data="instructions")) 111 | if admin: 112 | builder.row( 113 | InlineKeyboardButton(text="📊 Администратор", callback_data=AdminPanelCallback(action="admin").pack()) 114 | ) 115 | if SHOW_START_MENU_ONCE: 116 | builder.row(InlineKeyboardButton(text=ABOUT_VPN, callback_data="about_vpn")) 117 | else: 118 | builder.row(InlineKeyboardButton(text=BACK, callback_data="start")) 119 | 120 | await edit_or_send_message( 121 | target_message=target_message, 122 | text=profile_message, 123 | reply_markup=builder.as_markup(), 124 | media_path=image_path, 125 | disable_web_page_preview=False, 126 | force_text=True, 127 | ) 128 | finally: 129 | await conn.close() 130 | -------------------------------------------------------------------------------- /img/gifts.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/gifts.jpg -------------------------------------------------------------------------------- /img/instructions.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/instructions.jpg -------------------------------------------------------------------------------- /img/notify_10h.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/notify_10h.jpg -------------------------------------------------------------------------------- /img/notify_24h.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/notify_24h.jpg -------------------------------------------------------------------------------- /img/notify_expired.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/notify_expired.jpg -------------------------------------------------------------------------------- /img/pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/pic.jpg -------------------------------------------------------------------------------- /img/pic_invite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/pic_invite.jpg -------------------------------------------------------------------------------- /img/pic_keys.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/pic_keys.jpg -------------------------------------------------------------------------------- /img/pic_view.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/pic_view.jpg -------------------------------------------------------------------------------- /img/profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/profile.jpg -------------------------------------------------------------------------------- /img/tariffs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/img/tariffs.jpg -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | 5 | from datetime import timedelta 6 | 7 | from loguru import logger 8 | 9 | 10 | log_folder = "logs" 11 | 12 | if not os.path.exists(log_folder): 13 | os.makedirs(log_folder) 14 | 15 | logger.remove() 16 | 17 | level_mapping = { 18 | 50: "CRITICAL", 19 | 40: "ERROR", 20 | 30: "WARNING", 21 | 20: "INFO", 22 | 10: "DEBUG", 23 | 0: "NOTSET", 24 | } 25 | 26 | 27 | class InterceptHandler(logging.Handler): 28 | def emit(self, record): 29 | logger_opt = logger.opt(depth=6, exception=record.exc_info) 30 | message = record.getMessage() 31 | logger_opt.log(level_mapping.get(record.levelno, "INFO"), message) 32 | 33 | 34 | logging.basicConfig(handlers=[InterceptHandler()], level=0) 35 | logging.getLogger("httpcore").setLevel(logging.WARNING) 36 | logging.getLogger("httpx").setLevel(logging.WARNING) 37 | 38 | logger.add( 39 | sys.stderr, 40 | level="INFO", 41 | format="