├── .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 | SoloBot 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 | ![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131) 40 | 41 | Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot). 42 | 43 | ## Отзывы пользователей: 44 | 45 | #### SoloBot уже помог сотням пользователей в нашем сообществе: 46 | 47 | ![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30) 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="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} | {message}", 42 | colorize=True, 43 | ) 44 | 45 | log_file_path = os.path.join(log_folder, "logging.log") 46 | logger.add( 47 | log_file_path, 48 | level="DEBUG", 49 | format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{function}:{line} | {message}", 50 | rotation=timedelta(minutes=60), 51 | retention=timedelta(days=3), 52 | ) 53 | 54 | logger = logger 55 | -------------------------------------------------------------------------------- /middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | from aiogram import Dispatcher 4 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 5 | 6 | from .admin import AdminMiddleware 7 | from .loggings import LoggingMiddleware 8 | from .maintenance import MaintenanceModeMiddleware 9 | from .session import SessionMiddleware 10 | from .throttling import ThrottlingMiddleware 11 | from .user import UserMiddleware 12 | 13 | 14 | def register_middleware( 15 | dispatcher: Dispatcher, 16 | middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None, 17 | exclude: Iterable[str] | None = None, 18 | ) -> None: 19 | """Регистрирует middleware в диспетчере.""" 20 | if middlewares is None: 21 | available_middlewares = { 22 | "admin": AdminMiddleware(), 23 | "session": SessionMiddleware(), 24 | "maintenance": MaintenanceModeMiddleware(), 25 | "logging": LoggingMiddleware(), 26 | "throttling": ThrottlingMiddleware(), 27 | "user": UserMiddleware(), 28 | } 29 | 30 | exclude_set = set(exclude or []) 31 | middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set] 32 | 33 | handlers = [ 34 | dispatcher.message, 35 | dispatcher.callback_query, 36 | dispatcher.inline_query, 37 | ] 38 | 39 | for middleware in middlewares: 40 | if isinstance(middleware, type): 41 | middleware = middleware() 42 | 43 | for handler in handlers: 44 | handler.outer_middleware(middleware) 45 | -------------------------------------------------------------------------------- /middlewares/admin.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any 3 | 4 | from aiogram import BaseMiddleware 5 | from aiogram.types import CallbackQuery, Message, TelegramObject 6 | 7 | from config import ADMIN_ID 8 | 9 | 10 | class AdminMiddleware(BaseMiddleware): 11 | """Middleware для проверки прав администратора. 12 | 13 | Добавляет в data['admin'] = True/False в зависимости от того, 14 | является ли пользователь администратором. 15 | """ 16 | 17 | _admin_ids: set[int] = set(ADMIN_ID) if isinstance(ADMIN_ID, list | tuple) else {ADMIN_ID} 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: dict[str, Any], 24 | ) -> Any: 25 | """Обрабатывает событие и добавляет флаг администратора в data. 26 | 27 | Args: 28 | handler: Обработчик события 29 | event: Событие Telegram 30 | data: Словарь с данными события 31 | 32 | Returns: 33 | Результат выполнения обработчика 34 | """ 35 | data["admin"] = self._check_admin_access(event) 36 | return await handler(event, data) 37 | 38 | def _check_admin_access(self, event: TelegramObject) -> bool: 39 | """Проверяет, имеет ли пользователь права администратора. 40 | 41 | Args: 42 | event: Событие Telegram 43 | 44 | Returns: 45 | True, если пользователь администратор, иначе False 46 | """ 47 | try: 48 | if isinstance(event, Message): 49 | return event.from_user and event.from_user.id in self._admin_ids 50 | elif isinstance(event, CallbackQuery): 51 | return event.from_user and event.from_user.id in self._admin_ids 52 | 53 | user_id = getattr(getattr(event, "from_user", None), "id", None) 54 | return user_id in self._admin_ids if user_id else False 55 | except Exception: 56 | return False 57 | -------------------------------------------------------------------------------- /middlewares/loggings.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any, TypedDict 3 | 4 | from aiogram import BaseMiddleware 5 | from aiogram.types import CallbackQuery, InlineQuery, Message, TelegramObject, User 6 | 7 | from logger import logger 8 | 9 | 10 | class UserInfo(TypedDict): 11 | user_id: int | None 12 | username: str | None 13 | action: str | None 14 | 15 | 16 | class LoggingMiddleware(BaseMiddleware): 17 | """Middleware для логирования действий пользователя.""" 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: dict[str, Any], 24 | ) -> Any: 25 | user_info = self._extract_user_info(event) 26 | 27 | if user_info["user_id"]: 28 | logger.info( 29 | f"Активность пользователя - " 30 | f"ID пользователя: {user_info['user_id']}, " 31 | f"Имя пользователя: {user_info['username'] or 'Не указано'}, " 32 | f"Действие: {user_info['action'] or 'Неизвестно'}" 33 | ) 34 | 35 | return await handler(event, data) 36 | 37 | def _extract_user_info(self, event: TelegramObject) -> UserInfo: 38 | """Извлекает информацию о пользователе из различных типов событий. 39 | 40 | Args: 41 | event: Событие Telegram 42 | 43 | Returns: 44 | Словарь с информацией о пользователе 45 | """ 46 | result: UserInfo = {"user_id": None, "username": None, "action": None} 47 | 48 | if hasattr(event, "from_user") and isinstance(event.from_user, User): 49 | result["user_id"] = event.from_user.id 50 | result["username"] = event.from_user.username 51 | 52 | if isinstance(event, Message): 53 | result["action"] = f"Сообщение: {event.text}" 54 | elif isinstance(event, CallbackQuery): 55 | result["action"] = f"Обратный вызов: {event.data}" 56 | elif isinstance(event, InlineQuery): 57 | result["action"] = f"Inline запрос: {event.query}" 58 | 59 | return result 60 | -------------------------------------------------------------------------------- /middlewares/maintenance.py: -------------------------------------------------------------------------------- 1 | from aiogram import BaseMiddleware 2 | from aiogram.types import CallbackQuery, Message 3 | 4 | from config import ADMIN_ID 5 | 6 | 7 | maintenance_mode = False 8 | 9 | 10 | class MaintenanceModeMiddleware(BaseMiddleware): 11 | async def __call__(self, handler, event, data): 12 | if maintenance_mode: 13 | user_id = None 14 | if isinstance(event, Message): 15 | user_id = event.from_user.id 16 | elif isinstance(event, CallbackQuery): 17 | user_id = event.from_user.id 18 | 19 | if user_id and user_id not in ADMIN_ID: 20 | await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.") 21 | return 22 | 23 | return await handler(event, data) 24 | -------------------------------------------------------------------------------- /middlewares/session.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any 3 | 4 | import asyncpg 5 | 6 | from aiogram import BaseMiddleware 7 | from aiogram.types import TelegramObject 8 | 9 | from config import DATABASE_URL 10 | 11 | 12 | class SessionMiddleware(BaseMiddleware): 13 | pool: asyncpg.Pool | None = None 14 | 15 | async def __call__( 16 | self, 17 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 18 | event: TelegramObject, 19 | data: dict[str, Any], 20 | ) -> Any: 21 | if self.pool is None: 22 | self.pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20) 23 | 24 | async with self.pool.acquire() as conn: 25 | data["session"] = conn 26 | return await handler(event, data) 27 | 28 | @classmethod 29 | async def close(cls) -> None: 30 | """Закрыть пул соединений при завершении работы приложения.""" 31 | if cls.pool is not None: 32 | await cls.pool.close() 33 | cls.pool = None 34 | -------------------------------------------------------------------------------- /middlewares/throttling.py: -------------------------------------------------------------------------------- 1 | from aiogram import BaseMiddleware, Bot 2 | from aiogram.types import CallbackQuery 3 | from cachetools import TTLCache 4 | 5 | 6 | class ThrottlingMiddleware(BaseMiddleware): 7 | def __init__(self) -> None: 8 | self.cache = TTLCache(maxsize=10_000, ttl=1.0) 9 | self.throttle_notice_cache = TTLCache(maxsize=10_000, ttl=1.0) 10 | 11 | async def __call__(self, handler, event, data): 12 | user_id = event.from_user.id if event.from_user else None 13 | if user_id is None: 14 | return await handler(event, data) 15 | 16 | current_count = self.cache.get(user_id, 0) 17 | 18 | if current_count >= 3: 19 | if isinstance(event, CallbackQuery) and user_id not in self.throttle_notice_cache: 20 | self.throttle_notice_cache[user_id] = None 21 | bot: Bot = data["bot"] 22 | await bot.answer_callback_query( 23 | callback_query_id=event.id, 24 | text="Слишком много запросов! Пожалуйста, подождите...", 25 | show_alert=False, 26 | ) 27 | return None 28 | else: 29 | self.cache[user_id] = current_count + 1 30 | 31 | return await handler(event, data) 32 | -------------------------------------------------------------------------------- /middlewares/user.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import Any 3 | 4 | from aiogram import BaseMiddleware 5 | from aiogram.types import TelegramObject, User 6 | 7 | from database import upsert_user 8 | from logger import logger 9 | 10 | 11 | class UserMiddleware(BaseMiddleware): 12 | """ 13 | Middleware для обработки информации о пользователе. 14 | Сохраняет или обновляет данные пользователя в базе данных. 15 | """ 16 | 17 | async def __call__( 18 | self, 19 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 20 | event: TelegramObject, 21 | data: dict[str, Any], 22 | ) -> Any: 23 | try: 24 | if user := data.get("event_from_user"): 25 | session = data.get("session") 26 | db_user = await self._process_user(user, session) 27 | if db_user: 28 | data["user"] = db_user 29 | except Exception as e: 30 | logger.error(f"Ошибка при обработке пользователя: {e}") 31 | 32 | return await handler(event, data) 33 | 34 | async def _process_user(self, user: User, session: Any = None) -> dict: 35 | """ 36 | Обрабатывает информацию о пользователе и сохраняет её в базу данных. 37 | 38 | Args: 39 | user (User): Объект пользователя Telegram 40 | session (Any, optional): Сессия базы данных, если доступна 41 | 42 | Returns: 43 | dict: Словарь с информацией о пользователе из базы данных 44 | """ 45 | logger.debug(f"Обработка пользователя: {user.id}") 46 | user_data = await upsert_user( 47 | tg_id=user.id, 48 | username=user.username, 49 | first_name=user.first_name, 50 | last_name=user.last_name, 51 | language_code=user.language_code, 52 | is_bot=user.is_bot, 53 | session=session, 54 | only_if_exists=True, 55 | ) 56 | 57 | logger.debug(f"Получены данные пользователя из БД: {user.id}") 58 | return user_data 59 | -------------------------------------------------------------------------------- /panels/remnawave.cpython-312-x86_64-linux-gnu.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/panels/remnawave.cpython-312-x86_64-linux-gnu.so -------------------------------------------------------------------------------- /panels/three_xui.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | import httpx 7 | import py3xui 8 | 9 | from py3xui import AsyncApi 10 | 11 | from config import ( 12 | ADMIN_PASSWORD, 13 | ADMIN_USERNAME, 14 | LIMIT_IP, 15 | SUPERNODE, 16 | USE_XUI_TOKEN, 17 | XUI_TOKEN, 18 | ) 19 | from logger import logger 20 | 21 | 22 | @dataclass 23 | class ClientConfig: 24 | """Конфигурация клиента для добавления/обновления.""" 25 | 26 | client_id: str 27 | email: str 28 | tg_id: str 29 | limit_ip: int 30 | total_gb: int 31 | expiry_time: int 32 | enable: bool 33 | flow: str 34 | inbound_id: int 35 | sub_id: str 36 | 37 | 38 | _xui_instance_cache: dict[str, tuple[AsyncApi, float]] = {} 39 | SESSION_TTL = 1800 40 | 41 | 42 | async def get_xui_instance(api_url: str) -> AsyncApi: 43 | key = f"{api_url}|{ADMIN_USERNAME}" 44 | current_time = time.time() 45 | 46 | xui_entry = _xui_instance_cache.get(key) 47 | if xui_entry: 48 | xui, last_login = xui_entry 49 | if current_time - last_login < SESSION_TTL: 50 | return xui 51 | else: 52 | logger.info("[XUI Cache] Сессия устарела (>30 минут), переподключение...") 53 | await xui.login() 54 | _xui_instance_cache[key] = (xui, current_time) 55 | return xui 56 | 57 | xui = AsyncApi( 58 | api_url, 59 | ADMIN_USERNAME, 60 | ADMIN_PASSWORD, 61 | token=XUI_TOKEN if USE_XUI_TOKEN else None, 62 | logger=logger, 63 | ) 64 | await xui.login() 65 | _xui_instance_cache[key] = (xui, current_time) 66 | return xui 67 | 68 | 69 | async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]: 70 | try: 71 | client = py3xui.Client( 72 | id=config.client_id, 73 | email=config.email.lower(), 74 | limit_ip=config.limit_ip, 75 | total_gb=config.total_gb, 76 | expiry_time=config.expiry_time, 77 | enable=config.enable, 78 | tg_id=config.tg_id, 79 | sub_id=config.sub_id, 80 | flow=config.flow, 81 | ) 82 | 83 | response = await xui.client.add(config.inbound_id, [client]) 84 | logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}") 85 | return response if response else {"status": "failed"} 86 | 87 | except httpx.ConnectTimeout as e: 88 | logger.error(f"Ошибка при добавлении клиента {config.email}: {e}") 89 | return {"status": "failed", "error": "Timeout"} 90 | 91 | except Exception as e: 92 | error_message = str(e) 93 | if "Duplicate email" in error_message: 94 | logger.warning(f"Дублированный email: {config.email}. Пропуск. Сообщение: {error_message}") 95 | return {"status": "duplicate", "email": config.email} 96 | 97 | logger.error(f"Ошибка при добавлении клиента {config.email}: {error_message}") 98 | return {"status": "failed", "error": error_message} 99 | 100 | 101 | async def extend_client_key( 102 | xui: py3xui.AsyncApi, 103 | inbound_id: int, 104 | email: str, 105 | new_expiry_time: int, 106 | client_id: str, 107 | total_gb: int, 108 | sub_id: str, 109 | tg_id: int, 110 | ) -> bool | None: 111 | try: 112 | client = await xui.client.get_by_email(email) 113 | if not client or not client.id: 114 | logger.warning(f"Клиент с email {email} не найден или не имеет ID.") 115 | return None 116 | 117 | logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}") 118 | 119 | client.id = client_id 120 | client.expiry_time = new_expiry_time 121 | client.flow = "xtls-rprx-vision" 122 | client.sub_id = sub_id 123 | client.total_gb = total_gb 124 | client.enable = True 125 | client.limit_ip = LIMIT_IP 126 | client.inbound_id = inbound_id 127 | client.tg_id = tg_id 128 | 129 | await xui.client.update(client.id, client) 130 | await xui.client.reset_stats(inbound_id, email) 131 | logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}") 132 | return True 133 | 134 | except httpx.ConnectTimeout as e: 135 | logger.error(f"Ошибка при обновлении клиента {email}: {e}") 136 | return False 137 | 138 | except Exception as e: 139 | logger.error(f"Ошибка при обновлении клиента с email {email}: {e}") 140 | return False 141 | 142 | 143 | async def delete_client( 144 | xui: py3xui.AsyncApi, 145 | inbound_id: int, 146 | email: str, 147 | client_id: str, 148 | ) -> bool: 149 | """ 150 | Удаляет клиента с сервера 3x-ui. 151 | 152 | Args: 153 | xui: Экземпляр API клиента 154 | inbound_id: ID входящего соединения 155 | email: Email клиента 156 | client_id: ID клиента 157 | 158 | Returns: 159 | bool: True если удаление успешно, False в противном случае 160 | """ 161 | try: 162 | if SUPERNODE: 163 | await xui.client.delete(inbound_id, client_id) 164 | logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)") 165 | return True 166 | 167 | client = await xui.client.get_by_email(email) 168 | if not client: 169 | logger.warning(f"Клиент с email {email} и ID {client_id} не найден") 170 | return False 171 | 172 | client.id = client_id 173 | await xui.client.delete(inbound_id, client.id) 174 | logger.info(f"Клиент с ID {client_id} был удален успешно") 175 | return True 176 | 177 | except httpx.ConnectTimeout as e: 178 | logger.error(f"Ошибка при удалении клиента {email}: {e}") 179 | return False 180 | 181 | except Exception as e: 182 | logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}") 183 | return False 184 | 185 | 186 | async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]: 187 | try: 188 | traffic_data = await xui.client.get_traffic_by_id(client_id) 189 | if not traffic_data: 190 | logger.warning(f"Трафик для клиента {client_id} не найден.") 191 | return {"status": "not_found", "client_id": client_id} 192 | 193 | logger.info(f"Трафик для клиента {client_id} успешно получен.") 194 | return {"status": "success", "client_id": client_id, "traffic": traffic_data} 195 | 196 | except httpx.ConnectTimeout as e: 197 | logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}") 198 | return {"status": "error", "error": "Timeout"} 199 | 200 | except Exception as e: 201 | logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}") 202 | return {"status": "error", "error": str(e)} 203 | 204 | 205 | async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, enable: bool = True) -> bool: 206 | try: 207 | client = await xui.client.get_by_email(email) 208 | if not client: 209 | logger.warning(f"Клиент с email {email} и ID {client_id} не найден.") 210 | return False 211 | 212 | client.sub_id = email 213 | client.enable = enable 214 | client.id = client_id 215 | client.flow = "xtls-rprx-vision" 216 | client.limit_ip = LIMIT_IP 217 | client.inbound_id = inbound_id 218 | 219 | await xui.client.update(client.id, client) 220 | status = "включен" if enable else "отключен" 221 | logger.info(f"Клиент с email {email} и ID {client_id} успешно {status}.") 222 | return True 223 | 224 | except httpx.ConnectTimeout as e: 225 | status = "включении" if enable else "отключении" 226 | logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}") 227 | return False 228 | 229 | except Exception as e: 230 | status = "включении" if enable else "отключении" 231 | logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}") 232 | return False 233 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Solo_bot" 3 | version = "0.0.1" 4 | dependencies = [ 5 | "aiofiles==24.1.0", 6 | "aiogram==3.13.1", 7 | "aiohappyeyeballs==2.4.3", 8 | "aiohttp==3.10.10", 9 | "aiosignal==1.3.1", 10 | "annotated-types==0.7.0", 11 | "async-timeout==4.0.3", 12 | "asyncpg==0.30.0", 13 | "attrs==24.2.0", 14 | "certifi", 15 | "charset-normalizer==3.4.0", 16 | "deprecated==1.2.14", 17 | "distro==1.9.0", 18 | "frozenlist==1.4.1", 19 | "idna==3.10", 20 | "magic-filter==1.0.12", 21 | "multidict==6.1.0", 22 | "netaddr==1.3.0", 23 | "propcache==0.2.0", 24 | "pydantic", 25 | "pydantic-core", 26 | "requests==2.32.3", 27 | "typing-extensions==4.12.2", 28 | "urllib3==2.2.3", 29 | "wrapt==1.16.0", 30 | "yarl==1.15.5", 31 | "yookassa==3.3.0", 32 | "loguru", 33 | "aiocryptopay", 34 | "py3xui", 35 | "sqlalchemy", 36 | "robokassa", 37 | "ping3", 38 | "ruff", 39 | "pytz", 40 | "cachetools", 41 | "babel", 42 | ] 43 | 44 | [tool.ruff] 45 | line-length = 120 46 | target-version = "py312" 47 | fix = true 48 | unsafe-fixes = true 49 | preview = true 50 | 51 | [tool.uv] 52 | package = false 53 | 54 | [tool.ruff.lint] 55 | select = ["E", "F", "W", "I", "N", "UP", "ANN", "ASYNC", "S", "BLE", "FBT", "B", "A", "C4", "DTZ", "T10", "ISC", "ICN", "G", "PIE"] 56 | ignore = ["ANN101", "ANN102", "S101", "ANN201", "ANN001", "BLE001", "W291", "ANN401", "DTZ003", "DTZ005", "F401", "FBT002", "FBT001", "FBT003", "A005", "E501", "UP017", "DTZ004", "W293", "ANN202", "DTZ007"] 57 | exclude = [ 58 | ".git", 59 | "venv", 60 | "main.py", 61 | "handlers/payments", 62 | ] 63 | 64 | [tool.ruff.format] 65 | quote-style = "double" 66 | indent-style = "space" 67 | skip-magic-trailing-comma = false 68 | line-ending = "auto" 69 | docstring-code-format = true 70 | docstring-code-line-length = 88 71 | 72 | [tool.ruff.lint.isort] 73 | case-sensitive = true 74 | combine-as-imports = true 75 | force-wrap-aliases = true 76 | known-first-party = ["Solo_bot"] 77 | lines-after-imports = 2 78 | lines-between-types = 1 79 | 80 | [tool.ruff.lint.flake8-quotes] 81 | docstring-quotes = "double" 82 | inline-quotes = "double" 83 | multiline-quotes = "double" 84 | 85 | [tool.ruff.lint.pydocstyle] 86 | convention = "google" 87 | 88 | [tool.ruff.lint.pycodestyle] 89 | max-doc-length = 120 90 | 91 | [tool.ruff.lint.mccabe] 92 | max-complexity = 10 93 | 94 | [tool.darker] 95 | src = ["."] 96 | revision = "HEAD" 97 | diff = false 98 | check = false 99 | exclude = [ 100 | ".git", 101 | "venv", 102 | "main.py", 103 | "handlers/payments", 104 | ] 105 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiocryptopay==0.4.7 2 | aiofiles==24.1.0 3 | aiogram==3.13.1 4 | aiohappyeyeballs==2.4.3 5 | aiohttp==3.10.10 6 | aiosignal==1.3.1 7 | annotated-types==0.7.0 8 | anyio==4.8.0 9 | async-timeout==4.0.3 10 | asyncpg==0.30.0 11 | attrs==24.2.0 12 | babel==2.17.0 13 | cachetools==5.5.1 14 | certifi==2023.11.17 15 | charset-normalizer==3.4.0 16 | deprecated==1.2.14 17 | distro==1.9.0 18 | frozenlist==1.4.1 19 | greenlet==3.1.1 20 | h11==0.14.0 21 | httpcore==1.0.7 22 | httpx==0.27.2 23 | idna==3.10 24 | loguru==0.7.3 25 | magic-filter==1.0.12 26 | multidict==6.1.0 27 | netaddr==1.3.0 28 | ping3==4.0.8 29 | propcache==0.2.0 30 | py3xui==0.3.4 31 | pydantic==2.9.2 32 | pydantic-core==2.23.4 33 | pytz==2025.1 34 | requests==2.32.3 35 | robokassa==0.3.2 36 | ruff==0.9.5 37 | sniffio==1.3.1 38 | sqlalchemy==2.0.38 39 | strenum==0.4.15 40 | typing-extensions==4.12.2 41 | urllib3==2.2.3 42 | wrapt==1.16.0 43 | yarl==1.15.5 44 | yookassa==3.3.0 45 | httpx 46 | qrcode 47 | pillow 48 | rich -------------------------------------------------------------------------------- /servers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | 4 | from datetime import datetime, timedelta 5 | 6 | from aiogram.types import InlineKeyboardButton 7 | from aiogram.utils.keyboard import InlineKeyboardBuilder 8 | from ping3 import ping 9 | 10 | from bot import bot 11 | from config import ADMIN_ID, PING_TIME 12 | from database import get_servers 13 | from handlers.admin.servers.keyboard import AdminServerCallback 14 | from logger import logger 15 | 16 | 17 | last_ping_times = {} 18 | last_down_times = {} 19 | notified_servers = set() 20 | PING_SEMAPHORE = asyncio.Semaphore(3) 21 | 22 | 23 | async def ping_server(server_ip: str) -> bool: 24 | """Пингует сервер через ICMP или TCP 443, если ICMP недоступен.""" 25 | async with PING_SEMAPHORE: 26 | try: 27 | response = ping(server_ip, timeout=3) 28 | if response is not None and response is not False: 29 | return True 30 | return await check_tcp_connection(server_ip, 443) 31 | except Exception: 32 | return await check_tcp_connection(server_ip, 443) 33 | 34 | 35 | async def check_tcp_connection(host: str, port: int) -> bool: 36 | """Проверяет доступность сервера через TCP (порт 443).""" 37 | try: 38 | _reader, writer = await asyncio.open_connection(host, port) 39 | writer.close() 40 | await writer.wait_closed() 41 | return True 42 | except Exception: 43 | return False 44 | 45 | 46 | async def notify_admin(server_name: str, status: str, down_duration: timedelta = None): 47 | """Отправляет уведомление администратору.""" 48 | builder = InlineKeyboardBuilder() 49 | builder.row( 50 | InlineKeyboardButton( 51 | text="Управление сервером", 52 | callback_data=AdminServerCallback(action="manage", data=server_name).pack(), 53 | ) 54 | ) 55 | 56 | if status == "down": 57 | message = ( 58 | f"❌ Сервер '{server_name}' не отвечает!\n\n" 59 | "Проверьте соединение или удалите его из списка, чтобы не выдавать подписки на неработающий сервер." 60 | ) 61 | else: 62 | downtime = str(down_duration).split(".")[0] 63 | message = f"✅ Сервер '{server_name}' снова в сети!\n\n⏳ Время простоя: {downtime}." 64 | 65 | for admin_id in ADMIN_ID: 66 | logger.info(f"📨 Отправляем уведомление '{status}' администратору {admin_id} о сервере {server_name}") 67 | await bot.send_message(admin_id, message, reply_markup=builder.as_markup()) 68 | 69 | 70 | async def check_servers(): 71 | """ 72 | Периодическая проверка серверов. 73 | Использует asyncio.gather() для ускорения. 74 | """ 75 | while True: 76 | servers = await get_servers() 77 | current_time = datetime.now() 78 | 79 | tasks = [] 80 | server_info_list = [] 81 | 82 | for _, cluster_servers in servers.items(): 83 | for server in cluster_servers: 84 | original_api_url = server["api_url"] 85 | server_name = server["server_name"] 86 | server_host = extract_host(original_api_url) 87 | 88 | server_info_list.append((server_name, server_host)) 89 | tasks.append(ping_server(server_host)) 90 | 91 | logger.info(f"🔍 Начинаем проверку {len(server_info_list)} серверов...") 92 | 93 | results = await asyncio.gather(*tasks, return_exceptions=True) 94 | 95 | offline_servers = set() 96 | restored_servers = set() 97 | online_servers = set() 98 | 99 | for (server_name, server_host), result in zip(server_info_list, results, strict=False): 100 | is_online = bool(result) if not isinstance(result, Exception) else False 101 | 102 | if is_online: 103 | last_ping_times[server_name] = current_time 104 | online_servers.add(server_name) 105 | 106 | if server_name in notified_servers: 107 | down_time = last_down_times.pop(server_name, current_time) 108 | down_duration = current_time - down_time 109 | await notify_admin(server_name, "up", down_duration) 110 | 111 | notified_servers.remove(server_name) 112 | restored_servers.add(server_name) 113 | 114 | else: 115 | last_ping_time = last_ping_times.get(server_name) 116 | 117 | if last_ping_time is None: 118 | last_ping_times[server_name] = current_time 119 | last_down_times[server_name] = current_time 120 | 121 | if last_ping_time and (current_time - last_ping_time > timedelta(seconds=PING_TIME * 3)): 122 | if server_name not in notified_servers: 123 | logger.warning( 124 | f"🚨 Уведомление: сервер {server_name} не отвечает более {PING_TIME * 3} секунд!" 125 | ) 126 | await notify_admin(server_name, "down") 127 | notified_servers.add(server_name) 128 | last_down_times[server_name] = current_time 129 | offline_servers.add(server_name) 130 | 131 | all_servers = {name for name, _ in server_info_list} 132 | true_offline_servers = all_servers - online_servers 133 | 134 | logger.info(f"✅ Доступно серверов: {len(online_servers)}, ❌ Недоступно: {len(true_offline_servers)}") 135 | 136 | if true_offline_servers: 137 | logger.warning(f"🚨 Не отвечает {len(true_offline_servers)} серверов: {', '.join(true_offline_servers)}") 138 | if restored_servers: 139 | logger.info(f"✅ Восстановились {len(restored_servers)} серверов: {', '.join(restored_servers)}") 140 | 141 | await asyncio.sleep(PING_TIME) 142 | 143 | 144 | def extract_host(api_url: str) -> str: 145 | """Извлекает хост из `api_url`.""" 146 | match = re.match(r"(https?://)?([^:/]+)", api_url) 147 | return match.group(2) if match else api_url 148 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vladless/Solo_bot/8813664f3f3d24e0ed73a9fca526c9b34601ca7c/utils/__init__.py -------------------------------------------------------------------------------- /utils/csv_export.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from datetime import datetime 4 | from io import StringIO 5 | from typing import Any 6 | 7 | from aiogram.types import BufferedInputFile 8 | 9 | 10 | async def export_users_csv(session: Any) -> BufferedInputFile: 11 | """ 12 | Экспорт пользователей в CSV с сортировкой от самого старого к новому. 13 | """ 14 | query = """ 15 | SELECT 16 | tg_id, 17 | username, 18 | first_name, 19 | last_name, 20 | language_code, 21 | is_bot, 22 | balance, 23 | trial, 24 | created_at 25 | FROM users 26 | ORDER BY created_at ASC 27 | """ 28 | 29 | users = await session.fetch(query) 30 | 31 | buffer = StringIO() 32 | buffer.write("tg_id,username,first_name,last_name,language_code,is_bot,balance,trial,created_at\n") 33 | 34 | for user in users: 35 | buffer.write( 36 | f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']}," 37 | f"{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}," 38 | f"{user['created_at']}\n" 39 | ) 40 | 41 | buffer.seek(0) 42 | 43 | return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename="users_export.csv") 44 | 45 | 46 | async def export_payments_csv(session: Any) -> BufferedInputFile: 47 | """ 48 | Экспорт платежей в CSV с сортировкой от самого старого к новому. 49 | """ 50 | query = """ 51 | SELECT 52 | u.tg_id, 53 | u.username, 54 | u.first_name, 55 | u.last_name, 56 | p.amount, 57 | p.payment_system, 58 | p.status, 59 | p.created_at 60 | FROM users u 61 | JOIN payments p ON u.tg_id = p.tg_id 62 | ORDER BY p.created_at ASC -- Сортировка по дате от старых к новым 63 | """ 64 | payments = await session.fetch(query) 65 | return _export_payments_csv(payments, "payments_export.csv") 66 | 67 | 68 | async def export_user_payments_csv(tg_id: int, session: Any) -> BufferedInputFile: 69 | query = """ 70 | SELECT 71 | u.tg_id, 72 | u.username, 73 | u.first_name, 74 | u.last_name, 75 | p.amount, 76 | p.payment_system, 77 | p.status, 78 | p.created_at 79 | FROM users u 80 | JOIN payments p ON u.tg_id = p.tg_id 81 | WHERE u.tg_id = $1 82 | """ 83 | payments = await session.fetch(query, tg_id) 84 | return _export_payments_csv(payments, f"payments_export_{tg_id}.csv") 85 | 86 | 87 | def _export_payments_csv(payments: list, filename: str) -> BufferedInputFile: 88 | buffer = StringIO() 89 | buffer.write("tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n") 90 | 91 | for payment in payments: 92 | buffer.write( 93 | f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']}," 94 | f"{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n" 95 | ) 96 | 97 | buffer.seek(0) 98 | 99 | return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename=filename) 100 | 101 | 102 | async def export_referrals_csv(referrer_tg_id: int, session: Any) -> BufferedInputFile | None: 103 | """ 104 | Формирует CSV-файл со списком рефералов и возвращает его как BufferedInputFile. 105 | Если у пользователя нет рефералов, возвращает None. 106 | """ 107 | rows = await session.fetch( 108 | """ 109 | SELECT 110 | r.referred_tg_id, 111 | COALESCE(u.first_name, '') AS first_name, 112 | COALESCE(u.last_name, '') AS last_name, 113 | COALESCE(u.username, '') AS username 114 | FROM referrals r 115 | JOIN users u ON u.tg_id = r.referred_tg_id 116 | WHERE r.referrer_tg_id = $1 117 | ORDER BY r.referred_tg_id 118 | """, 119 | referrer_tg_id, 120 | ) 121 | 122 | if not rows: 123 | return None 124 | 125 | output = StringIO() 126 | writer = csv.writer(output, delimiter=";") 127 | writer.writerow(["Приглашённый (tg_id)", "Имя"]) 128 | 129 | for row in rows: 130 | invited_id = row["referred_tg_id"] 131 | full_name = row["first_name"].strip() or row["username"] or str(invited_id) 132 | if row["last_name"]: 133 | full_name = f"{full_name} {row['last_name']}" 134 | writer.writerow([invited_id, full_name.strip()]) 135 | 136 | output.seek(0) 137 | csv_data = output.getvalue().encode("utf-8") 138 | filename = f"referrals_{referrer_tg_id}.csv" 139 | 140 | return BufferedInputFile(file=csv_data, filename=filename) 141 | 142 | 143 | async def export_hot_leads_csv(session: Any) -> BufferedInputFile: 144 | """ 145 | Экспорт пользователей, которые делали платежи, но сейчас не имеют ключей. 146 | Возвращает: tg_id, username, first_name, last_name, updated_at 147 | """ 148 | query = """ 149 | SELECT DISTINCT u.tg_id, u.username, u.first_name, u.last_name, u.updated_at 150 | FROM users u 151 | JOIN payments p ON u.tg_id = p.tg_id 152 | LEFT JOIN keys k ON u.tg_id = k.tg_id 153 | WHERE p.status = 'success' 154 | AND k.tg_id IS NULL 155 | ORDER BY u.updated_at DESC 156 | """ 157 | 158 | users = await session.fetch(query) 159 | 160 | buffer = StringIO() 161 | buffer.write("tg_id,username,first_name,last_name,updated_at\n") 162 | 163 | for user in users: 164 | buffer.write( 165 | f"{user['tg_id']},{user['username'] or ''}," 166 | f"{user['first_name'] or ''},{user['last_name'] or ''}," 167 | f"{user['updated_at']}\n" 168 | ) 169 | 170 | buffer.seek(0) 171 | return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename="hot_leads_export.csv") 172 | 173 | 174 | async def export_keys_csv(session) -> BufferedInputFile: 175 | """ 176 | Экспорт подписок в CSV с нормальными датами. 177 | """ 178 | keys = await session.fetch(""" 179 | SELECT tg_id, client_id, email, created_at, expiry_time, key, server_id, is_frozen, alias 180 | FROM keys 181 | ORDER BY created_at ASC 182 | """) 183 | 184 | buffer = StringIO() 185 | buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n") 186 | 187 | for row in keys: 188 | created_at = datetime.utcfromtimestamp(row["created_at"] / 1000).strftime("%Y-%m-%d %H:%M:%S") 189 | expiry_time = datetime.utcfromtimestamp(row["expiry_time"] / 1000).strftime("%Y-%m-%d %H:%M:%S") 190 | 191 | buffer.write( 192 | f"{row['tg_id']},{row['client_id']},{row['email']}," 193 | f"{created_at},{expiry_time},{row['key']}," 194 | f"{row['server_id']},{row['is_frozen']},{row['alias'] or ''}\n" 195 | ) 196 | 197 | buffer.seek(0) 198 | return BufferedInputFile(file=buffer.getvalue().encode("utf-8-sig"), filename="keys_export.csv") 199 | -------------------------------------------------------------------------------- /web/__init__.py: -------------------------------------------------------------------------------- 1 | from aiohttp.web_urldispatcher import UrlDispatcher 2 | 3 | import bot 4 | 5 | 6 | async def register_web_routes(router: UrlDispatcher) -> None: 7 | dp = bot.dp 8 | 9 | # todo: add your api routes here 10 | --------------------------------------------------------------------------------