Compare commits

...

42 Commits

Author SHA1 Message Date
Fr1ngg 9b14baca51 Update README.md 2025-08-08 05:43:08 +03:00
Fr1ngg eb1a75c9f5 Update .env.example 2025-08-08 05:24:58 +03:00
Fr1ngg 5e79106134 Update admin_handlers.py 2025-08-08 05:22:43 +03:00
Fr1ngg ee45ec356e Update api_error_handlers.py 2025-08-08 05:22:20 +03:00
Fr1ngg 9275bff828 Update config.py 2025-08-08 05:21:51 +03:00
Fr1ngg 9150c70618 Update database.py 2025-08-08 05:21:25 +03:00
Fr1ngg b792e749d6 Update handlers.py 2025-08-08 05:21:00 +03:00
Fr1ngg 9871572740 Update keyboards.py 2025-08-08 05:20:37 +03:00
Fr1ngg 52c1cb3cbc Update main.py 2025-08-08 05:20:16 +03:00
Fr1ngg d5b2b371c8 Update middlewares.py 2025-08-08 05:19:23 +03:00
Fr1ngg d5cb022b02 Update remnawave_api.py 2025-08-08 05:19:06 +03:00
Fr1ngg 021f62fd30 Update subscription_monitor.py 2025-08-08 05:18:41 +03:00
Fr1ngg 684e398798 Update translations.py 2025-08-08 05:18:12 +03:00
Fr1ngg 12b64e9978 Update utils.py 2025-08-08 05:17:45 +03:00
Fr1ngg 273df23f20 Update README.md 2025-08-07 09:32:39 +03:00
Fr1ngg a9b0dbf3eb Update handlers.py 2025-08-07 09:31:28 +03:00
Fr1ngg 5f56e068d3 Update remnawave_api.py 2025-08-07 09:27:59 +03:00
Fr1ngg 92b82132d8 Update remnawave_api.py 2025-08-07 09:27:42 +03:00
Fr1ngg 79ae9d3a1c Update config.py 2025-08-07 09:25:55 +03:00
Fr1ngg 86714a93f9 Update handlers.py 2025-08-07 09:25:40 +03:00
Fr1ngg 553f6f8cf6 Update handlers.py 2025-08-07 09:24:22 +03:00
Fr1ngg b561f67c12 Update utils.py 2025-08-07 09:23:52 +03:00
Fr1ngg d03f9b80c7 Update translations.py 2025-08-07 09:23:26 +03:00
Fr1ngg 6d9a5fb578 Update README.md 2025-08-07 08:23:07 +03:00
Fr1ngg 44d19245c4 Update admin_handlers.py 2025-08-07 08:19:54 +03:00
Fr1ngg ce36c268cc Update main.py 2025-08-07 07:53:09 +03:00
Fr1ngg 1357b07921 Update middlewares.py 2025-08-07 07:52:49 +03:00
Fr1ngg d4bfdcb749 Update config.py 2025-08-07 07:52:33 +03:00
Fr1ngg 39621706c8 Update subscription_monitor.py 2025-08-07 07:52:01 +03:00
Fr1ngg 4684e7a1e5 Update utils.py 2025-08-07 07:51:36 +03:00
Fr1ngg 16bb3c8ad3 Update translations.py 2025-08-07 07:51:15 +03:00
Fr1ngg 16fbdcc655 Create api_error_handlers.py 2025-08-07 07:50:56 +03:00
Fr1ngg 416d268352 Update remnawave_api.py 2025-08-07 07:50:30 +03:00
Fr1ngg 44fb88f368 Update keyboards.py 2025-08-07 07:50:06 +03:00
Fr1ngg f050c12253 Update database.py 2025-08-07 07:49:48 +03:00
Fr1ngg 82486d121e Update handlers.py 2025-08-07 07:49:25 +03:00
Fr1ngg 2bf382dbac Update handlers.py 2025-08-07 07:49:06 +03:00
Fr1ngg ab562e0df0 Update handlers.py 2025-08-07 07:48:48 +03:00
Fr1ngg 02e8fba9d0 Update admin_handlers.py 2025-08-07 07:48:21 +03:00
Fr1ngg 530df33ba4 Update README.md 2025-08-05 15:41:41 +03:00
Fr1ngg 8b2e8cb681 Update README.md 2025-08-05 15:40:27 +03:00
Fr1ngg 2cb8c937e1 Update README.md 2025-08-05 15:39:33 +03:00
14 changed files with 8215 additions and 1309 deletions
+7 -2
View File
@@ -1,17 +1,22 @@
# Bot Configuration
BOT_TOKEN=
# настройки реф программы юзернейм без @!
BOT_USERNAME=
REFERRAL_FIRST_REWARD=100.0
REFERRAL_REFERRED_BONUS=100.0
REFERRAL_THRESHOLD=200.0
REFERRAL_PERCENTAGE=0.2
# RemnaWave API Configuration
REMNAWAVE_URL=https://example.com
REMNAWAVE_MODE=remote
REMNAWAVE_TOKEN=
SUBSCRIPTION_BASE_URL=https://example.com
# Admin Configuration (comma-separated list of Telegram user IDs)
ADMIN_IDS=123445567,12342123
# Support Configuration
DEFAULT_LANGUAGE=ru
SUPPORT_USERNAME=support
# Database Configuration (optional, defaults to SQLite)
+45 -17
View File
@@ -1,9 +1,9 @@
<img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 15 13" src="https://github.com/user-attachments/assets/91098622-1bce-4f27-afef-60a3c5b5061f" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 22" src="https://github.com/user-attachments/assets/46b87e75-b420-4ac6-91b9-8c7e9bcffb2a" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 39" src="https://github.com/user-attachments/assets/ca97811f-ca00-4133-a120-1c11f0efa0fc" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 45" src="https://github.com/user-attachments/assets/258e1adb-2c39-4126-82a7-7791b56d42db" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 53" src="https://github.com/user-attachments/assets/073455fc-f42d-4d70-839d-59042add2d94" /><img width="906" height="316" alt="Снимок экрана 2025-08-05 в 03 16 00" src="https://github.com/user-attachments/assets/2034dde8-a48b-4149-a23f-b788aa40e0b1" /><img width="894" height="317" alt="Снимок экрана 2025-08-05 в 15 32 32" src="https://github.com/user-attachments/assets/a96337cf-f58a-488e-9600-c94a92bdbfc2" /><img width="906" height="366" alt="Снимок экрана 2025-08-05 в 03 16 18" src="https://github.com/user-attachments/assets/3a3d1e0a-92fc-4573-a48c-f36481d6d0de" /><img width="906" height="842" alt="Снимок экрана 2025-08-05 в 03 17 24" src="https://github.com/user-attachments/assets/8b407f69-6861-4810-822e-c3f7b8f63629" /><img width="906" height="274" alt="Снимок экрана 2025-08-05 в 03 17 43" src="https://github.com/user-attachments/assets/923a945a-5ef8-4dcb-9804-fffc37ab8887" /><img width="936" height="364" alt="Снимок экрана 2025-08-05 в 03 20 03" src="https://github.com/user-attachments/assets/1faecdfe-f80c-4ac2-ad38-81a30fc6623d" />
<img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 15 13" src="https://github.com/user-attachments/assets/91098622-1bce-4f27-afef-60a3c5b5061f" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 22" src="https://github.com/user-attachments/assets/46b87e75-b420-4ac6-91b9-8c7e9bcffb2a" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 39" src="https://github.com/user-attachments/assets/ca97811f-ca00-4133-a120-1c11f0efa0fc" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 45" src="https://github.com/user-attachments/assets/258e1adb-2c39-4126-82a7-7791b56d42db" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 53" src="https://github.com/user-attachments/assets/073455fc-f42d-4d70-839d-59042add2d94" /><img width="906" height="316" alt="Снимок экрана 2025-08-05 в 03 16 00" src="https://github.com/user-attachments/assets/2034dde8-a48b-4149-a23f-b788aa40e0b1" /><img width="894" height="317" alt="Снимок экрана 2025-08-05 в 15 32 32" src="https://github.com/user-attachments/assets/a96337cf-f58a-488e-9600-c94a92bdbfc2" /><img width="906" height="366" alt="Снимок экрана 2025-08-05 в 03 16 18" src="https://github.com/user-attachments/assets/3a3d1e0a-92fc-4573-a48c-f36481d6d0de" /><img width="906" height="842" alt="Снимок экрана 2025-08-05 в 03 17 24" src="https://github.com/user-attachments/assets/8b407f69-6861-4810-822e-c3f7b8f63629" /><img width="906" height="274" alt="Снимок экрана 2025-08-05 в 03 17 43" src="https://github.com/user-attachments/assets/923a945a-5ef8-4dcb-9804-fffc37ab8887" /><img width="936" height="364" alt="Снимок экрана 2025-08-05 в 03 20 03" src="https://github.com/user-attachments/assets/1faecdfe-f80c-4ac2-ad38-81a30fc6623d" /><img width="892" height="486" alt="Снимок экрана 2025-08-07 в 07 43 47" src="https://github.com/user-attachments/assets/0dd6cb8e-fd2f-4a98-8920-aadceee09fd0" /><img width="892" height="762" alt="Снимок экрана 2025-08-07 в 07 44 20" src="https://github.com/user-attachments/assets/d7c95e3e-cf04-40bc-9422-d7289447625d" /><img width="892" height="823" alt="Снимок экрана 2025-08-07 в 07 46 45" src="https://github.com/user-attachments/assets/9ab2c378-0abc-447d-9e95-a3ab8dab2f18" />
<img width="892" height="501" alt="Снимок экрана 2025-08-07 в 07 42 07" src="https://github.com/user-attachments/assets/839c02da-4461-4127-894a-772e66175e23" /><img width="892" height="805" alt="Снимок экрана 2025-08-07 в 07 57 01" src="https://github.com/user-attachments/assets/bc35f79d-0b0d-4c81-8623-696b708642ad" /><img width="892" height="834" alt="Снимок экрана 2025-08-07 в 07 41 09" src="https://github.com/user-attachments/assets/d4731a79-0171-4254-aa78-2e4c7305829b" /><img width="631" height="606" alt="Снимок экрана 2025-08-06 в 18 48 31" src="https://github.com/user-attachments/assets/c44548b0-f27b-4f67-b3c0-2c002ae33979" /><img width="866" height="655" alt="Снимок экрана 2025-08-08 в 05 08 56" src="https://github.com/user-attachments/assets/2957b76c-8b2b-4a32-8ca4-44cbe3c8fa7b" /><img width="866" height="256" alt="Снимок экрана 2025-08-08 в 05 10 29" src="https://github.com/user-attachments/assets/39dbece4-6004-475e-815a-a520499c8839" /><img width="866" height="195" alt="Снимок экрана 2025-08-08 в 05 10 54" src="https://github.com/user-attachments/assets/b3196856-9f36-46d9-a1f5-93b71699174f" /><img width="894" height="212" alt="Снимок экрана 2025-08-08 в 05 11 18" src="https://github.com/user-attachments/assets/47b4b625-93d2-48c7-ae3a-a6a96811f935" /><img width="894" height="173" alt="Снимок экрана 2025-08-08 в 05 29 42" src="https://github.com/user-attachments/assets/600eecc7-e255-421f-a355-a410332e4313" />
#Описание
RemnaWave Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
RemnaWave Bedolaga Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
Бот интегрирован с системой RemnaWave версии 2.0.8
@@ -11,9 +11,9 @@ RemnaWave Telegram Bot — это многофункциональный бот
Мультиязычный интерфейс (на данный момент русский и английский языки)
Создание и покупка подписок с управлением трафиком, длительностью и ценой
Создание и покупка подписок
Бесплатная тестовая подписка с ограничениями
Бесплатная тестовая подписка с заданными ограничениями(срок, лимит трафика, назначение сквада)
Пополнение баланса: 1) Через саппорт в ручную 2) Отправка заявки с суммой админу (С возможность подтвердить/отклонить заявку)
@@ -21,6 +21,8 @@ RemnaWave Telegram Bot — это многофункциональный бот
Промокоды со скидками и ограничениями по использованию
Реф. программа - С несколькими уровнями: 1) награда первое пополнение приглашенному и владельцу реф ссылки. 2) Награда владельцу за каждое последующее пополнение в процентах от суммы пополнения
Полноценная админ-панель с контролем пользователей, созданием подписок(Подтягивает UUID сквада из панели), платежей и статистикой
Рассылка сообщений отдельным пользователям и всем сразу
@@ -29,6 +31,10 @@ RemnaWave Telegram Bot — это многофункциональный бот
Интеграция с RemnaWave API для управления подписками и пользователями RemnaWave
Полная синхранизация Remnawave <--> Bot - Перенос подписок из панели Remnawave в бот по Telegram id
Управление системой Remnawave (NEW)
Управление платежами (подтверждение, отклонение) + История платежей(Все действия с балансом и подписками в постраничной истории)
@@ -59,7 +65,6 @@ URL и токен RemnaWave API
REMNAWAVE_URL=https://your-remnawave-url.ru
REMNAWAVE_MODE=remote/local
REMNAWAVE_TOKEN=ваш_remnawave_token
SUBSCRIPTION_BASE_URL=https://
DATABASE_URL=sqlite+aiosqlite:///bot.db
ADMIN_IDS=123456789,987654321
DEFAULT_LANGUAGE=ru
@@ -69,13 +74,16 @@ URL и токен RemnaWave API
TRIAL_TRAFFIC_GB=2
TRIAL_SQUAD_UUID=19bd5bde-5eea-4368-809c-6ba1ffb93897
TRIAL_PRICE=0.0
MONITOR_CHECK_INTERVAL=3600
MONITOR_DAILY_CHECK_HOUR=10
MONITOR_WARNING_DAYS=2
3. Соберите образ (Makefile Dockerfile docker-compose):
4. Соберите образ (Makefile Dockerfile docker-compose):
make build
4. Запуск:
5. Запуск:
Запуск минимальной конфигурации (бот + база данных):
@@ -111,6 +119,16 @@ URL и токен RemnaWave API
BOT_TOKEN — токен Telegram бота от BotFather.
BOT_USERNAME= (Юзернейм бота для работы реф программы)
REFERRAL_FIRST_REWARD=100.0 (Награда за пополнение рефа)
REFERRAL_REFERRED_BONUS=100.0 (Награда рефу за первое пополнение)
REFERRAL_THRESHOLD=200.0 (Сумма на которую должен пополниться реф, чтобы выдало бонус всем)
REFERRAL_PERCENTAGE=0.2 (Процент за каждое последующее пополнение рефа, работает со второго пополнения)
REMNAWAVE_URL — URL API RemnaWave.
REMNAWAVE_MODE=remote
@@ -119,8 +137,6 @@ REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
DATABASE_URL — строка подключения к базе данных.
SUBSCRIPTION_BASE_URL=https://sub.example.com (без / на конце)
ADMIN_IDS — через запятую Telegram ID администраторов.
SUPPORT_USERNAME — ник поддержки, без @ указывать
@@ -148,7 +164,17 @@ MONITOR_WARNING_DAYS=2 (За сколько дней слать уведомле
#Использование
/start
/start - запуск
#Синхронизация подписок
Вы можете перенести свои существующие подписки из панели Remnawave прямо в бота всего одним кликом.
Для этого в админ панеле реализован соостветствующий пункт: Админ панель - Система Remnawave - Синхронизация с Remnawave - Импорт всех по Telegram ID. После нажатия подтянет всех пользователей в бота, подпискам из панели будет назначено имя "Старая подписка" - такую подписку невозможно продлить.
ДОПОЛНИТЕЛЬНО:
Реализована возможность зачистки импортированных из панели подписок по тг айди Админ панель - Система Remnawave - Синхронизация с Remnawave - Просмотрт планов - Удалалить импортированные
Остальное трогать без понимания кода - не рекомендую.
#Структура проекта
@@ -200,17 +226,19 @@ run.sh — скрипт установки и управления ботом (
Просмотр статистики
Управление систеой Remnawave (Ноды, пользователи, синхронизация и импорт подписок из базы Remnawave в бот)
#ToDo
Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
1) Дописать службу для оповещения об истечении срока подписки и контроля - Done v1.1.0
2) Подключить различные шлюзы для пополнения баланса
3) Синхранизацию с Remnawave между пользователями по тг id
3) Синхранизацию с Remnawave между пользователями по тг id - Done v1.2.0
4) Полнофункциональную панель упарвления
5) Добавить возможность удаление промокодов - In progress
6) Доработать алгоритм удаления подписок - In progress
ибо удаление(А НЕ деактивация) сейчас - скроект эту подписку у всех юзеров которые ее купили,
так что удаляйте на свой страх и риск я предупредил)
6) Доработать алгоритм удаления подписок ибо удаление(А НЕ деактивация) сейчас - скроект эту подписку у всех юзеров которые ее купили, так что удаляйте на свой страх и риск я предупредил) - In progress
8) Отправка уведомлений административных в другие чаты-топики
9) Рефка
(как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю)
9) Рефка (как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю), ладно трех запросов в личку мне хватило для того. чтобы пересмотреть свое мнение и вкорячить эту шляпу приоритетом - Done v 1.3.0
10) Интегрировать панелья управления Remnawave прямо в бота - Done v1.1.0
А так, други, буду рад баг репортам, по настройке тоже не проблема подсказать, стучите в лс или @fringg
+4265 -20
View File
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
import logging
from typing import Optional, Dict, Any, Callable
from aiogram.types import CallbackQuery
from aiogram import Router
from remnawave_api import RemnaWaveAPI
from translations import t
logger = logging.getLogger(__name__)
class APIErrorHandler:
"""Класс для обработки ошибок API и предоставления пользователю понятной информации"""
@staticmethod
async def handle_api_error(callback: CallbackQuery, error: Exception,
operation: str, user_language: str = 'ru',
fallback_keyboard=None) -> bool:
"""
Обработка ошибок API с отправкой понятного сообщения пользователю
Returns:
bool: True если ошибка была обработана, False если нужно перепробросить
"""
error_message = str(error).lower()
if "timeout" in error_message or "connection" in error_message:
text = "⏱ Таймаут подключения к API\n\n"
text += "Возможные причины:\n"
text += "• Медленный интернет\n"
text += "• Перегрузка сервера RemnaWave\n"
text += "• Временные проблемы с сетью\n\n"
text += "🔄 Попробуйте повторить операцию через несколько секунд"
elif "401" in error_message or "unauthorized" in error_message:
text = "🔐 Ошибка авторизации API\n\n"
text += "Токен доступа недействителен или истек.\n"
text += "Обратитесь к администратору для обновления токена."
elif "404" in error_message or "not found" in error_message:
text = f"❌ Ресурс не найден\n\n"
text += f"Операция: {operation}\n"
text += "Возможно, запрашиваемый объект был удален или не существует."
elif "500" in error_message or "internal server error" in error_message:
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
text += "Сервер временно недоступен.\n"
text += "Попробуйте повторить операцию позже."
else:
text = f"❌ Ошибка API операции: {operation}\n\n"
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
text += "Обратитесь к администратору если проблема повторяется."
try:
await callback.message.edit_text(
text,
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
)
return True
except Exception as edit_error:
logger.error(f"Failed to edit message with error info: {edit_error}")
try:
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
return True
except:
return False
@staticmethod
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
"""
Безопасный вызов метода API с обработкой ошибок
Returns:
tuple: (success: bool, result: Any)
"""
try:
result = await api_method(*args, **kwargs)
return True, result
except Exception as e:
logger.error(f"API call failed: {api_method.__name__} - {e}")
return False, str(e)
# Дополнительные обработчики для исправления конкретных проблем
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
"""Создание клавиатуры для восстановления после ошибки"""
from keyboards import error_recovery_keyboard
return error_recovery_keyboard(error_context, language)
# Улучшенные функции для работы с RemnaWave API
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
"""Безопасное получение списка нод"""
try:
logger.info("Attempting to fetch nodes from API...")
nodes = await api.get_all_nodes()
if nodes is None:
logger.warning("API returned None for nodes")
return False, []
if not isinstance(nodes, list):
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
return False, []
logger.info(f"Successfully fetched {len(nodes)} nodes")
return True, nodes
except Exception as e:
logger.error(f"Error fetching nodes: {e}")
return False, []
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
"""Безопасное получение списка пользователей системы"""
try:
logger.info("Attempting to fetch system users from API...")
users = await api.get_all_system_users_full()
if users is None:
logger.warning("API returned None for users")
return False, []
if not isinstance(users, list):
logger.warning(f"API returned non-list for users: {type(users)}")
return False, []
logger.info(f"Successfully fetched {len(users)} users")
return True, users
except Exception as e:
logger.error(f"Error fetching system users: {e}")
return False, []
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
"""Безопасная перезагрузка нод"""
try:
if all_nodes:
logger.info("Attempting to restart all nodes...")
result = await api.restart_all_nodes()
else:
logger.info(f"Attempting to restart node {node_id}...")
result = await api.restart_node(node_id)
if result:
message = "Команда перезагрузки отправлена успешно"
logger.info(f"Restart command sent successfully")
return True, message
else:
message = "API вернул отрицательный результат"
logger.warning("API returned negative result for restart")
return False, message
except Exception as e:
logger.error(f"Error restarting nodes: {e}")
return False, str(e)
# Функции для проверки состояния API
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
"""Проверка состояния API"""
health_info = {
'api_available': False,
'nodes_accessible': False,
'users_accessible': False,
'system_stats_accessible': False,
'errors': []
}
if api is None:
health_info['errors'].append("API instance is None")
return health_info
# Проверяем доступность API
try:
# Простая проверка через получение нод (обычно быстрая операция)
success, nodes = await safe_get_nodes(api)
if success:
health_info['api_available'] = True
health_info['nodes_accessible'] = True
else:
health_info['errors'].append("Cannot fetch nodes")
except Exception as e:
health_info['errors'].append(f"Nodes check failed: {e}")
# Проверяем доступность пользователей
try:
success, users = await safe_get_system_users(api)
if success:
health_info['users_accessible'] = True
else:
health_info['errors'].append("Cannot fetch users")
except Exception as e:
health_info['errors'].append(f"Users check failed: {e}")
# Проверяем системную статистику
try:
stats = await api.get_system_stats()
if stats:
health_info['system_stats_accessible'] = True
else:
health_info['errors'].append("Cannot fetch system stats")
except Exception as e:
health_info['errors'].append(f"System stats check failed: {e}")
return health_info
# Декоратор для автоматической обработки ошибок API
def handle_api_errors(operation_name: str):
"""Декоратор для автоматической обработки ошибок API в handler'ах"""
def decorator(func):
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
try:
return await func(callback, user, *args, **kwargs)
except Exception as e:
logger.error(f"Error in {func.__name__}: {e}")
# Получаем API из kwargs если есть
api = kwargs.get('api')
fallback_keyboard = None
# Создаем fallback клавиатуру в зависимости от операции
if 'nodes' in operation_name.lower():
from keyboards import admin_system_keyboard
fallback_keyboard = admin_system_keyboard(user.language)
elif 'users' in operation_name.lower():
from keyboards import system_users_keyboard
fallback_keyboard = system_users_keyboard(user.language)
# Обрабатываем ошибку
await APIErrorHandler.handle_api_error(
callback, e, operation_name, user.language, fallback_keyboard
)
return wrapper
return decorator
+154 -55
View File
@@ -1,73 +1,172 @@
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List
import logging
try:
from dotenv import load_dotenv
load_dotenv()
print("✅ .env файл загружен успешно")
except ImportError:
print("⚠️ python-dotenv не установлен. Используются системные переменные окружения.")
except Exception as e:
print(f"⚠️ Ошибка загрузки .env файла: {e}")
logger = logging.getLogger(__name__)
@dataclass
class Config:
BOT_TOKEN: str
REMNAWAVE_URL: str
REMNAWAVE_TOKEN: str
REMNAWAVE_MODE: str
DATABASE_URL: str
ADMIN_IDS: List[int]
DEFAULT_LANGUAGE: str
SUPPORT_USERNAME: str
# Bot settings
BOT_TOKEN: str = field(default="")
DATABASE_URL: str = field(default="sqlite+aiosqlite:///bot.db")
# Subscription URL settings
SUBSCRIPTION_BASE_URL: str
# RemnaWave API settings
REMNAWAVE_URL: str = field(default="")
REMNAWAVE_TOKEN: str = field(default="")
SUBSCRIPTION_BASE_URL: str = field(default="")
# Admin settings
ADMIN_IDS: List[int] = field(default_factory=list)
SUPPORT_USERNAME: str = field(default="support")
# Bot settings
DEFAULT_LANGUAGE: str = field(default="ru")
BOT_USERNAME: str = field(default="") # ИМЯ БОТА ДЛЯ РЕФЕРАЛЬНЫХ ССЫЛОК
# Trial subscription settings
TRIAL_ENABLED: bool
TRIAL_DURATION_DAYS: int
TRIAL_TRAFFIC_GB: int
TRIAL_SQUAD_UUID: str
TRIAL_PRICE: float
TRIAL_ENABLED: bool = field(default=False)
TRIAL_DURATION_DAYS: int = field(default=3)
TRIAL_TRAFFIC_GB: int = field(default=2)
TRIAL_SQUAD_UUID: str = field(default="")
# Monitor service settings
MONITOR_CHECK_INTERVAL: int
MONITOR_DAILY_CHECK_HOUR: int
MONITOR_WARNING_DAYS: int
MONITOR_WARNING_DAYS: int = field(default=3)
MONITOR_CHECK_INTERVAL: int = field(default=3600) # 1 час
MONITOR_DAILY_CHECK_HOUR: int = field(default=9) # 9 утра
# Referral program settings
REFERRAL_FIRST_REWARD: float = field(default=150.0) # Первая награда
REFERRAL_REFERRED_BONUS: float = field(default=150.0) # Бонус приглашенному
REFERRAL_THRESHOLD: float = field(default=300.0) # Порог для получения бонуса
REFERRAL_PERCENTAGE: float = field(default=0.25) # 25% с платежей
def parse_admin_ids(admin_ids_str: str) -> List[int]:
"""Parse admin IDs from string"""
if not admin_ids_str:
return []
try:
# Support both comma and space separated
ids_str = admin_ids_str.replace(',', ' ').strip()
return [int(id_str.strip()) for id_str in ids_str.split() if id_str.strip().isdigit()]
except ValueError as e:
logger.error(f"Error parsing admin IDs: {e}")
return []
def str_to_bool(value: str) -> bool:
"""Convert string to boolean"""
if isinstance(value, bool):
return value
return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
def load_config() -> Config:
"""Load configuration from environment variables"""
# Parse admin IDs
admin_ids_str = os.getenv('ADMIN_IDS', '')
admin_ids = []
if admin_ids_str:
try:
admin_ids = [int(x.strip()) for x in admin_ids_str.split(',') if x.strip()]
except ValueError:
admin_ids = []
# Get subscription base URL -
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
# Дебаг: проверяем что переменные загружены
print(f"🔍 BOT_USERNAME из env: '{os.getenv('BOT_USERNAME', 'НЕ НАЙДЕН')}'")
print(f"🔍 REFERRAL_FIRST_REWARD из env: '{os.getenv('REFERRAL_FIRST_REWARD', 'НЕ НАЙДЕН')}'")
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
if not subscription_base_url:
subscription_base_url = 'https://sub.fring.tech'
return Config(
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
REMNAWAVE_MODE=os.getenv('REMNAWAVE_MODE', 'local'),
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
ADMIN_IDS=admin_ids,
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
config = Config(
# Bot settings
BOT_TOKEN=os.getenv("BOT_TOKEN", ""),
DATABASE_URL=os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db"),
# Subscription URL
SUBSCRIPTION_BASE_URL=subscription_base_url,
# RemnaWave API settings
REMNAWAVE_URL=os.getenv("REMNAWAVE_URL", ""),
REMNAWAVE_TOKEN=os.getenv("REMNAWAVE_TOKEN", ""),
SUBSCRIPTION_BASE_URL=os.getenv("SUBSCRIPTION_BASE_URL", ""),
# Trial subscription settings
TRIAL_ENABLED=os.getenv('TRIAL_ENABLED', 'true').lower() == 'true',
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
# Admin settings
ADMIN_IDS=parse_admin_ids(os.getenv("ADMIN_IDS", "")),
SUPPORT_USERNAME=os.getenv("SUPPORT_USERNAME", "support"),
# Monitor service settings
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
# Bot settings
DEFAULT_LANGUAGE=os.getenv("DEFAULT_LANGUAGE", "ru"),
BOT_USERNAME=os.getenv("BOT_USERNAME", ""), # ИСПРАВЛЕНО: берется из env
# Trial settings
TRIAL_ENABLED=str_to_bool(os.getenv("TRIAL_ENABLED", "false")),
TRIAL_DURATION_DAYS=int(os.getenv("TRIAL_DURATION_DAYS", "3")),
TRIAL_TRAFFIC_GB=int(os.getenv("TRIAL_TRAFFIC_GB", "2")),
TRIAL_SQUAD_UUID=os.getenv("TRIAL_SQUAD_UUID", ""),
MONITOR_WARNING_DAYS=int(os.getenv("MONITOR_WARNING_DAYS", "3")),
MONITOR_CHECK_INTERVAL=int(os.getenv("MONITOR_CHECK_INTERVAL", "3600")),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv("MONITOR_DAILY_CHECK_HOUR", "9")),
# Referral settings
REFERRAL_FIRST_REWARD=float(os.getenv("REFERRAL_FIRST_REWARD", "150.0")),
REFERRAL_REFERRED_BONUS=float(os.getenv("REFERRAL_REFERRED_BONUS", "150.0")),
REFERRAL_THRESHOLD=float(os.getenv("REFERRAL_THRESHOLD", "300.0")),
REFERRAL_PERCENTAGE=float(os.getenv("REFERRAL_PERCENTAGE", "0.25")),
)
print(f"✅ Config BOT_USERNAME: '{config.BOT_USERNAME}'")
print(f"✅ Config REFERRAL_FIRST_REWARD: {config.REFERRAL_FIRST_REWARD}")
return config
def load_config_manual_dotenv() -> Config:
"""Load config with manual .env parsing"""
# Загружаем .env файл вручную если python-dotenv не доступен
env_path = ".env"
if os.path.exists(env_path):
try:
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
# Убираем кавычки если есть
value = value.strip('"\'')
os.environ[key] = value
print(f"✅ Файл {env_path} загружен вручную")
except Exception as e:
print(f"⚠️ Ошибка загрузки {env_path}: {e}")
return load_config()
def debug_environment():
"""Debug environment variables loading"""
print("\n=== DEBUG ENVIRONMENT VARIABLES ===")
# Ключевые переменные для проверки
key_vars = [
'BOT_TOKEN', 'BOT_USERNAME', 'REMNAWAVE_URL', 'REMNAWAVE_TOKEN',
'ADMIN_IDS', 'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD'
]
for var in key_vars:
value = os.getenv(var, 'НЕ УСТАНОВЛЕНА')
print(f"{var}: {value}")
print("=" * 40)
# Проверяем файл .env
if os.path.exists('.env'):
print("📁 Файл .env найден")
try:
with open('.env', 'r') as f:
lines = f.readlines()
print(f"📄 Строк в .env: {len(lines)}")
for i, line in enumerate(lines[:5], 1): # Первые 5 строк
if 'TOKEN' not in line: # Не показываем токены
print(f" {i}: {line.strip()}")
except Exception as e:
print(f"❌ Ошибка чтения .env: {e}")
else:
print("❌ Файл .env не найден в текущей директории")
print(f"📍 Текущая директория: {os.getcwd()}")
print("=" * 40 + "\n")
+546 -21
View File
@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer, text
from datetime import datetime
from typing import Optional, List
from typing import Optional, List, Dict
import logging
logger = logging.getLogger(__name__)
@@ -10,6 +10,29 @@ logger = logging.getLogger(__name__)
class Base(DeclarativeBase):
pass
class ReferralProgram(Base):
__tablename__ = 'referral_programs'
id: Mapped[int] = mapped_column(primary_key=True)
referrer_id: Mapped[int] = mapped_column(BigInteger, index=True) # Кто пригласил
referred_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) # Кто был приглашен
referral_code: Mapped[str] = mapped_column(String(20), index=True) # Промокод реферера
first_reward_paid: Mapped[bool] = mapped_column(Boolean, default=False) # Выплачена ли разовая награда
total_earned: Mapped[float] = mapped_column(Float, default=0.0) # Всего заработано
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
first_reward_at: Mapped[Optional[datetime]] = mapped_column(DateTime) # Когда выплатили первую награду
class ReferralEarning(Base):
__tablename__ = 'referral_earnings'
id: Mapped[int] = mapped_column(primary_key=True)
referrer_id: Mapped[int] = mapped_column(BigInteger, index=True) # Кто получил награду
referred_id: Mapped[int] = mapped_column(BigInteger, index=True) # От кого получена награда
amount: Mapped[float] = mapped_column(Float) # Размер награды
earning_type: Mapped[str] = mapped_column(String(20)) # 'first_reward', 'percentage'
related_payment_id: Mapped[Optional[int]] = mapped_column(Integer) # Связанный платеж
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class User(Base):
__tablename__ = 'users'
@@ -38,6 +61,7 @@ class Subscription(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
is_trial: Mapped[bool] = mapped_column(Boolean, default=False)
is_imported: Mapped[bool] = mapped_column(Boolean, default=False)
class UserSubscription(Base):
__tablename__ = 'user_subscriptions'
@@ -48,7 +72,9 @@ class UserSubscription(Base):
short_uuid: Mapped[str] = mapped_column(String(255)) # УБРАНО unique=True
expires_at: Mapped[datetime] = mapped_column(DateTime)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
traffic_limit_gb: Mapped[Optional[int]] = mapped_column(Integer) # Добавлено поле
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, onupdate=datetime.utcnow) # Добавлено поле
class Payment(Base):
__tablename__ = 'payments'
@@ -100,6 +126,53 @@ class Database:
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Выполняем миграции
await self.migrate_user_subscriptions()
await self.migrate_subscription_imported_field()
await self.migrate_referral_tables() # НОВАЯ МИГРАЦИЯ
async def migrate_referral_tables(self):
"""Create referral system tables if they don't exist"""
try:
async with self.engine.begin() as conn:
# Создаем таблицы реферальной системы
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS referral_programs (
id SERIAL PRIMARY KEY,
referrer_id BIGINT NOT NULL,
referred_id BIGINT UNIQUE NOT NULL,
referral_code VARCHAR(20) NOT NULL,
first_reward_paid BOOLEAN DEFAULT FALSE,
total_earned DOUBLE PRECISION DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
first_reward_at TIMESTAMP,
INDEX idx_referrer (referrer_id),
INDEX idx_referred (referred_id),
INDEX idx_referral_code (referral_code)
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS referral_earnings (
id SERIAL PRIMARY KEY,
referrer_id BIGINT NOT NULL,
referred_id BIGINT NOT NULL,
amount DOUBLE PRECISION NOT NULL,
earning_type VARCHAR(20) NOT NULL,
related_payment_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_referrer_earnings (referrer_id),
INDEX idx_referred_earnings (referred_id),
INDEX idx_earning_type (earning_type)
)
"""))
logger.info("Successfully created referral system tables")
except Exception as e:
logger.error(f"Error creating referral tables: {e}")
async def close(self):
await self.engine.dispose()
@@ -166,7 +239,7 @@ class Database:
return False
# Subscription methods
async def get_all_subscriptions(self, include_inactive: bool = False, exclude_trial: bool = True) -> List[Subscription]:
async def get_all_subscriptions(self, include_inactive: bool = False, exclude_trial: bool = True, exclude_imported: bool = True) -> List[Subscription]:
async with self.session_factory() as session:
try:
from sqlalchemy import select
@@ -175,12 +248,39 @@ class Database:
query = query.where(Subscription.is_active == True)
if exclude_trial:
query = query.where(Subscription.is_trial == False)
if exclude_imported:
query = query.where(Subscription.is_imported == False) # Исключаем импортированные
result = await session.execute(query)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting subscriptions: {e}")
return []
async def get_all_subscriptions_admin(self) -> List[Subscription]:
"""Get all subscriptions including imported ones (for admin purposes)"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(select(Subscription))
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting admin subscriptions: {e}")
return []
async def migrate_subscription_imported_field(self):
"""Add is_imported field to subscriptions table"""
try:
async with self.engine.begin() as conn:
try:
await conn.execute(text("""
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS is_imported BOOLEAN DEFAULT FALSE
"""))
logger.info("Successfully added is_imported field to subscriptions table")
except Exception as e:
logger.info(f"Migration may have already been applied: {e}")
except Exception as e:
logger.error(f"Error during subscription migration: {e}")
async def get_subscription_by_id(self, subscription_id: int) -> Optional[Subscription]:
async with self.session_factory() as session:
@@ -196,7 +296,7 @@ class Database:
async def create_subscription(self, name: str, description: str, price: float,
duration_days: int, traffic_limit_gb: int,
squad_uuid: str) -> Subscription:
squad_uuid: str, is_imported: bool = False) -> Subscription:
async with self.session_factory() as session:
try:
subscription = Subscription(
@@ -205,7 +305,8 @@ class Database:
price=price,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
squad_uuid=squad_uuid
squad_uuid=squad_uuid,
is_imported=is_imported # Добавляем поддержку is_imported
)
session.add(subscription)
await session.commit()
@@ -253,26 +354,47 @@ class Database:
logger.error(f"Error getting user subscriptions for {user_id}: {e}")
return []
async def create_user_subscription(self, user_id: int, subscription_id: int,
short_uuid: str, expires_at: datetime) -> UserSubscription:
async def create_user_subscription(self, user_id: int, subscription_id: int,
short_uuid: str, expires_at: datetime,
is_active: bool = True, traffic_limit_gb: int = None) -> Optional[UserSubscription]:
"""Create user subscription with proper error handling"""
async with self.session_factory() as session:
try:
user_sub = UserSubscription(
# Проверяем что подписка не существует
from sqlalchemy import select
existing = await session.execute(
select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.short_uuid == short_uuid
)
)
existing_sub = existing.scalar_one_or_none()
if existing_sub:
logger.warning(f"Subscription with short_uuid {short_uuid} already exists for user {user_id}")
return existing_sub
# Создаем новую подписку
new_subscription = UserSubscription(
user_id=user_id,
subscription_id=subscription_id,
short_uuid=short_uuid,
expires_at=expires_at
expires_at=expires_at,
is_active=is_active
)
session.add(user_sub)
session.add(new_subscription)
await session.commit()
await session.refresh(user_sub)
return user_sub
await session.refresh(new_subscription)
return new_subscription
except Exception as e:
logger.error(f"Error creating user subscription: {e}")
await session.rollback()
raise
return None
# Payment methods
# Payment methods
async def create_payment(self, user_id: int, amount: float, payment_type: str,
description: str, status: str = 'pending') -> Payment:
async with self.session_factory() as session:
@@ -467,17 +589,55 @@ class Database:
except Exception as e:
logger.error(f"Error getting trial subscriptions: {e}")
return []
async def update_user_subscription(self, user_sub: UserSubscription) -> UserSubscription:
async def get_user_subscription_by_short_uuid(self, user_id: int, short_uuid: str) -> Optional[UserSubscription]:
"""Get user subscription by short_uuid"""
async with self.session_factory() as session:
try:
await session.merge(user_sub)
await session.commit()
return user_sub
from sqlalchemy import select
result = await session.execute(
select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.short_uuid == short_uuid
)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error updating user subscription {user_sub.id}: {e}")
logger.error(f"Error getting user subscription by short_uuid: {e}")
return None
async def update_user_subscription(self, user_subscription: UserSubscription) -> bool:
"""Update user subscription"""
async with self.session_factory() as session:
try:
# Устанавливаем время обновления
user_subscription.updated_at = datetime.utcnow()
# Обновляем подписку
await session.merge(user_subscription)
await session.commit()
return True
except Exception as e:
logger.error(f"Error updating user subscription: {e}")
await session.rollback()
raise
return False
async def migrate_user_subscriptions(self):
"""Migrate user_subscriptions table to add missing columns"""
try:
async with self.engine.begin() as conn:
# Проверяем существование столбцов и добавляем их если нет
try:
await conn.execute(text("""
ALTER TABLE user_subscriptions
ADD COLUMN IF NOT EXISTS traffic_limit_gb INTEGER,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP
"""))
logger.info("Successfully migrated user_subscriptions table")
except Exception as e:
logger.info(f"Migration may have already been applied or error occurred: {e}")
except Exception as e:
logger.error(f"Error during migration: {e}")
async def get_expiring_subscriptions(self, user_id: int, days_threshold: int = 3) -> List[UserSubscription]:
async with self.session_factory() as session:
@@ -611,3 +771,368 @@ class Database:
except Exception as e:
logger.error(f"Error getting paginated payments by status: {e}")
return [], 0
async def get_user_subscriptions_by_plan_id(self, plan_id: int) -> List[UserSubscription]:
"""Get all user subscriptions for a specific plan"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(UserSubscription).where(UserSubscription.subscription_id == plan_id)
)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting user subscriptions for plan {plan_id}: {e}")
return []
async def delete_user_subscription(self, user_subscription_id: int) -> bool:
"""Delete user subscription by ID"""
async with self.session_factory() as session:
try:
from sqlalchemy import delete
result = await session.execute(
delete(UserSubscription).where(UserSubscription.id == user_subscription_id)
)
await session.commit()
return result.rowcount > 0
except Exception as e:
logger.error(f"Error deleting user subscription {user_subscription_id}: {e}")
await session.rollback()
return False
async def create_referral(self, referrer_id: int, referred_id: int, referral_code: str) -> Optional[ReferralProgram]:
"""Create referral relationship - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
if referred_id == 0:
# Генерируем уникальный placeholder ID для хранения кода
placeholder_id = 999999999 - referrer_id # Уникальный ID на основе referrer_id
# Проверяем что код еще не существует для этого пользователя
existing = await session.execute(
select(ReferralProgram).where(
ReferralProgram.referrer_id == referrer_id,
ReferralProgram.referred_id == placeholder_id
)
)
existing_referral = existing.scalar_one_or_none()
if existing_referral:
logger.info(f"Referral code already exists for user {referrer_id}")
return existing_referral
# Создаем запись для хранения кода
referral = ReferralProgram(
referrer_id=referrer_id,
referred_id=placeholder_id, # Уникальный placeholder
referral_code=referral_code
)
session.add(referral)
await session.commit()
await session.refresh(referral)
logger.info(f"Created referral code storage for user {referrer_id}")
return referral
# Обычная логика для реальных рефералов
existing = await session.execute(
select(ReferralProgram).where(
ReferralProgram.referred_id == referred_id,
ReferralProgram.referred_id < 900000000, # Исключаем placeholder
ReferralProgram.referred_id > 0 # Исключаем нулевые
)
)
if existing.scalar_one_or_none():
logger.info(f"User {referred_id} already has a real referrer")
return None
# Проверяем что пользователь не приглашает сам себя
if referrer_id == referred_id:
logger.warning(f"User {referrer_id} tried to refer themselves")
return None
# Создаем реальную реферальную связь
referral = ReferralProgram(
referrer_id=referrer_id,
referred_id=referred_id,
referral_code=referral_code
)
session.add(referral)
await session.commit()
await session.refresh(referral)
logger.info(f"Created real referral: {referrer_id} -> {referred_id}")
return referral
except Exception as e:
logger.error(f"Error creating referral: {e}")
await session.rollback()
return None
async def get_referral_by_referred_id(self, referred_id: int) -> Optional[ReferralProgram]:
"""Get referral info by referred user ID"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
result = await session.execute(
select(ReferralProgram).where(ReferralProgram.referred_id == referred_id)
)
return result.scalar_one_or_none()
except Exception as e:
logger.error(f"Error getting referral: {e}")
return None
async def get_user_referrals(self, referrer_id: int) -> List[ReferralProgram]:
"""Get all referrals for a user - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, and_
result = await session.execute(
select(ReferralProgram).where(
and_(
ReferralProgram.referrer_id == referrer_id,
# ИСПРАВЛЕНО: исключаем placeholder записи для хранения кодов
ReferralProgram.referred_id < 900000000,
ReferralProgram.referred_id > 0
)
)
)
referrals = list(result.scalars().all())
logger.debug(f"Found {len(referrals)} real referrals for user {referrer_id}")
return referrals
except Exception as e:
logger.error(f"Error getting user referrals: {e}")
return []
async def create_referral(self, referrer_id: int, referred_id: int, referral_code: str) -> Optional[ReferralProgram]:
"""Create referral relationship - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
async with self.session_factory() as session:
try:
from sqlalchemy import select
# Специальный случай: создание кода для самого пользователя
if referred_id == 0:
# Генерируем уникальный placeholder ID для хранения кода
placeholder_id = 999999999 - referrer_id # Уникальный ID на основе referrer_id
# Проверяем что код еще не существует для этого пользователя
existing = await session.execute(
select(ReferralProgram).where(
ReferralProgram.referrer_id == referrer_id,
ReferralProgram.referred_id == placeholder_id
)
)
existing_referral = existing.scalar_one_or_none()
if existing_referral:
logger.info(f"Referral code already exists for user {referrer_id}")
return existing_referral
# Создаем запись для хранения кода
referral = ReferralProgram(
referrer_id=referrer_id,
referred_id=placeholder_id, # Уникальный placeholder
referral_code=referral_code
)
session.add(referral)
await session.commit()
await session.refresh(referral)
return referral
# Обычная логика для реальных рефералов
existing = await session.execute(
select(ReferralProgram).where(ReferralProgram.referred_id == referred_id)
)
if existing.scalar_one_or_none():
logger.info(f"User {referred_id} already has a referrer")
return None
# Проверяем что пользователь не приглашает сам себя
if referrer_id == referred_id:
logger.warning(f"User {referrer_id} tried to refer themselves")
return None
referral = ReferralProgram(
referrer_id=referrer_id,
referred_id=referred_id,
referral_code=referral_code
)
session.add(referral)
await session.commit()
await session.refresh(referral)
return referral
except Exception as e:
logger.error(f"Error creating referral: {e}")
await session.rollback()
return None
async def get_user_referral_stats(self, user_id: int) -> Dict:
"""Get user referral statistics - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, func, and_, or_
placeholder_id = 999999999 - user_id
# Количество приглашенных (исключаем только конкретный placeholder этого пользователя)
referrals_count = await session.execute(
select(func.count(ReferralProgram.id))
.where(
and_(
ReferralProgram.referrer_id == user_id,
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
)
)
)
# Количество тех, кто получил первую награду (исключаем placeholder)
active_referrals = await session.execute(
select(func.count(ReferralProgram.id))
.where(
and_(
ReferralProgram.referrer_id == user_id,
ReferralProgram.first_reward_paid == True,
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
)
)
)
# Общий заработок
total_earned = await session.execute(
select(func.sum(ReferralEarning.amount))
.where(ReferralEarning.referrer_id == user_id)
)
result = {
'total_referrals': referrals_count.scalar() or 0,
'active_referrals': active_referrals.scalar() or 0,
'total_earned': total_earned.scalar() or 0.0
}
logger.info(f"Referral stats for user {user_id}: {result}")
return result
except Exception as e:
logger.error(f"Error getting referral stats: {e}")
return {
'total_referrals': 0,
'active_referrals': 0,
'total_earned': 0.0
}
async def generate_unique_referral_code(self, user_id: int) -> str:
"""Generate unique referral code for user"""
async with self.session_factory() as session:
try:
import secrets
import string
# Сначала пытаемся создать код на основе user_id
base_code = f"REF{user_id}"
from sqlalchemy import select
existing = await session.execute(
select(ReferralProgram).where(ReferralProgram.referral_code == base_code)
)
if not existing.scalar_one_or_none():
return base_code
# Если код уже существует, добавляем случайные символы
for _ in range(10):
random_suffix = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4))
code = f"REF{user_id}{random_suffix}"
existing = await session.execute(
select(ReferralProgram).where(ReferralProgram.referral_code == code)
)
if not existing.scalar_one_or_none():
return code
# Если все еще не удалось, используем полностью случайный код
return f"REF{''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))}"
except Exception as e:
logger.error(f"Error generating referral code: {e}")
return f"REF{user_id}ERR"
async def get_user_referrals(self, referrer_id: int) -> List[ReferralProgram]:
"""Get all referrals for a user - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
async with self.session_factory() as session:
try:
from sqlalchemy import select, and_
placeholder_id = 999999999 - referrer_id
result = await session.execute(
select(ReferralProgram).where(
and_(
ReferralProgram.referrer_id == referrer_id,
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
)
).order_by(ReferralProgram.created_at.desc()) # Сортируем по дате создания
)
referrals = list(result.scalars().all())
logger.info(f"Found {len(referrals)} real referrals for user {referrer_id} (excluding placeholder {placeholder_id})")
# Дополнительно логируем каждого реферала для отладки
for ref in referrals:
logger.debug(f"Referral: referrer={ref.referrer_id}, referred={ref.referred_id}, "
f"first_reward_paid={ref.first_reward_paid}, total_earned={ref.total_earned}")
return referrals
except Exception as e:
logger.error(f"Error getting user referrals: {e}")
return []
async def create_referral_earning(self, referrer_id: int, referred_id: int,
amount: float, earning_type: str,
related_payment_id: Optional[int] = None) -> bool:
"""Create referral earning record"""
async with self.session_factory() as session:
try:
earning = ReferralEarning(
referrer_id=referrer_id,
referred_id=referred_id,
amount=amount,
earning_type=earning_type,
related_payment_id=related_payment_id
)
session.add(earning)
# Обновляем общий заработок и статус первой награды в реферальной программе
from sqlalchemy import select, update
# Найти запись реферальной программы
referral = await session.execute(
select(ReferralProgram).where(
ReferralProgram.referrer_id == referrer_id,
ReferralProgram.referred_id == referred_id
)
)
referral_record = referral.scalar_one_or_none()
if referral_record:
# Обновляем total_earned
referral_record.total_earned += amount
# Если это первая награда, помечаем как выплаченную
if earning_type == 'first_reward':
referral_record.first_reward_paid = True
referral_record.first_reward_at = datetime.utcnow()
await session.merge(referral_record)
await session.commit()
return True
except Exception as e:
logger.error(f"Error creating referral earning: {e}")
await session.rollback()
return False
+707 -206
View File
File diff suppressed because it is too large Load Diff
+190 -33
View File
@@ -1,6 +1,6 @@
from database import Subscription
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from typing import List, Optional
from typing import List, Optional, Dict
from translations import t
def language_keyboard() -> InlineKeyboardMarkup:
@@ -31,9 +31,12 @@ def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: boo
# Добавляем остальные кнопки
buttons.extend([
# Дополнительные функции
# Реферальная программа и дополнительные функции
[
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
InlineKeyboardButton(text="👥 Рефералы", callback_data="referral_program") # НОВАЯ КНОПКА
],
[
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
],
# Последний ряд - настройки
@@ -59,7 +62,7 @@ def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful top up balance keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
# [InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
[InlineKeyboardButton(text="👨‍💼 " + t('topup_support', lang), callback_data="topup_support")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
])
@@ -122,16 +125,22 @@ def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru'
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False) -> InlineKeyboardMarkup:
"""Beautiful user's subscription detail keyboard with connection and optional extend button"""
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False, is_imported: bool = False) -> InlineKeyboardMarkup:
"""Beautiful user's subscription detail keyboard with connection and optional extend button - ИСПРАВЛЕНО"""
buttons = []
# Add extend button if subscription is expiring soon
if show_extend:
buttons.append([InlineKeyboardButton(text="" + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
# Connection button (главная кнопка)
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
# НОВОЕ: Разные кнопки в зависимости от типа подписки
if is_imported:
# Для импортированных подписок - только ссылка подключения и покупка новой
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
buttons.append([InlineKeyboardButton(text="🛒 Купить новую подписку", callback_data="buy_subscription")])
else:
# Для обычных подписок - кнопка продления если нужна
if show_extend:
buttons.append([InlineKeyboardButton(text="" + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
# Connection button (главная кнопка)
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
# Back button
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
@@ -177,9 +186,17 @@ def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
],
# Третий ряд - коммуникации и аналитика
# Третий ряд - коммуникации и система
[
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals") # НОВАЯ КНОПКА
],
[
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system")
],
# Четвертый ряд - мониторинг и статистика
[
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor"),
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
],
# Назад
@@ -347,27 +364,167 @@ def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
])
return keyboard
def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin menu keyboard"""
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Beautiful admin system management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
# Первый ряд - управление контентом
[
InlineKeyboardButton(text="📦 " + t('manage_subscriptions', lang), callback_data="admin_subscriptions"),
InlineKeyboardButton(text="👥 " + t('manage_users', lang), callback_data="admin_users")
],
# Второй ряд - финансы
[
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
],
# Третий ряд - коммуникации и аналитика
[
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
],
# Четвертый ряд - мониторинг (НОВОЕ!)
[InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor")],
# Назад
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""System statistics keyboard with refresh"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
])
return keyboard
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
"""Improved nodes management keyboard"""
buttons = []
if nodes:
# Statistics row
online_count = len([n for n in nodes if n.get('status') == 'online'])
total_count = len(nodes)
buttons.append([
InlineKeyboardButton(
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
callback_data="noop"
)
])
# Show first 5 nodes with improved display
for i, node in enumerate(nodes[:5]):
status = node.get('status', 'unknown')
# Status emoji based on actual status
if status == 'online':
status_emoji = "🟢"
elif status == 'disabled':
status_emoji = ""
elif status == 'disconnected':
status_emoji = "🔴"
elif status == 'xray_stopped':
status_emoji = "🟡"
else:
status_emoji = ""
node_name = node.get('name', f'Node-{i+1}')
node_id = node.get('id', node.get('uuid'))
# Truncate long names
if len(node_name) > 20:
display_name = node_name[:17] + "..."
else:
display_name = node_name
# CPU/Memory usage if available
usage_info = ""
if node.get('cpuUsage'):
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
if node.get('memUsage'):
usage_info += f" MEM:{node['memUsage']:.0f}%"
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {display_name}{usage_info}",
callback_data=f"node_details_{node_id}"
),
InlineKeyboardButton(
text="🔄",
callback_data=f"restart_node_{node_id}"
),
InlineKeyboardButton(
text="⚙️",
callback_data=f"node_settings_{node_id}"
)
])
if len(nodes) > 5:
buttons.append([
InlineKeyboardButton(
text=f"... и еще {len(nodes) - 5} нод",
callback_data="show_all_nodes"
)
])
else:
buttons.append([
InlineKeyboardButton(
text="❌ Ноды не найдены",
callback_data="noop"
)
])
# Action buttons
buttons.append([
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
])
# Refresh button
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
buttons.append([
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
])
# Back button
buttons.append([
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""System users management keyboard - ИСПРАВЛЕНО"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
])
return keyboard
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Bulk operations keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
])
return keyboard
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
"""Confirmation keyboard for node restart"""
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
]
])
return keyboard
def admin_referrals_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
"""Admin referral management keyboard"""
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Статистика рефералов", callback_data="referral_statistics")],
[InlineKeyboardButton(text="👥 Список рефереров", callback_data="list_referrers")],
[InlineKeyboardButton(text="💰 История выплат", callback_data="referral_payments")],
[InlineKeyboardButton(text="⚙️ Настройки программы", callback_data="referral_settings")],
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
])
return keyboard
+290 -66
View File
@@ -1,73 +1,297 @@
import asyncio
import logging
import sys
import os
from dataclasses import dataclass
from typing import List
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@dataclass
class Config:
BOT_TOKEN: str
REMNAWAVE_URL: str
REMNAWAVE_TOKEN: str
REMNAWAVE_MODE: str
DATABASE_URL: str
ADMIN_IDS: List[int]
DEFAULT_LANGUAGE: str
SUPPORT_USERNAME: str
# Subscription URL settings
SUBSCRIPTION_BASE_URL: str
# Trial subscription settings
TRIAL_ENABLED: bool
TRIAL_DURATION_DAYS: int
TRIAL_TRAFFIC_GB: int
TRIAL_SQUAD_UUID: str
TRIAL_PRICE: float
# Monitor service settings
MONITOR_CHECK_INTERVAL: int
MONITOR_DAILY_CHECK_HOUR: int
MONITOR_WARNING_DAYS: int
# ДОБАВЛЯЕМ ДЕБАГ ЗАГРУЗКИ .env ПЕРЕД ИМПОРТОМ CONFIG
print("🚀 Запуск бота...")
print(f"📍 Рабочая директория: {os.getcwd()}")
print(f"📁 Файлы в директории: {os.listdir('.')}")
def load_config() -> Config:
"""Load configuration from environment variables"""
# Parse admin IDs
admin_ids_str = os.getenv('ADMIN_IDS', '')
admin_ids = []
if admin_ids_str:
# Проверяем наличие .env файла
if os.path.exists('.env'):
print("✅ Файл .env найден")
else:
print("❌ Файл .env НЕ НАЙДЕН!")
print("💡 Создайте файл .env в корне проекта")
# Import our modules ПОСЛЕ проверки .env
from config import load_config, debug_environment
from database import Database
from remnawave_api import RemnaWaveAPI
from subscription_monitor import create_subscription_monitor
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
from handlers import router
from admin_handlers import admin_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('bot.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
class BotApplication:
"""Main bot application class"""
def __init__(self):
self.config = None
self.db = None
self.api = None
self.bot = None
self.dp = None
self.monitor_service = None
async def initialize(self):
"""Initialize all components"""
# ДЕБАГ: проверяем переменные окружения
debug_environment()
# Load configuration
self.config = load_config()
# ДЕБАГ: проверяем что загрузилось
print(f"🔧 Загруженная конфигурация:")
print(f" BOT_USERNAME: '{self.config.BOT_USERNAME}'")
print(f" REFERRAL_FIRST_REWARD: {self.config.REFERRAL_FIRST_REWARD}")
print(f" ADMIN_IDS: {self.config.ADMIN_IDS}")
# Validate required environment variables
if not self.config.BOT_TOKEN:
logger.error("BOT_TOKEN is required")
raise ValueError("BOT_TOKEN is required")
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
# ПРЕДУПРЕЖДЕНИЕ если BOT_USERNAME не установлен
if not self.config.BOT_USERNAME:
logger.warning("⚠️ BOT_USERNAME не установлен! Реферальные ссылки работать не будут!")
print("💡 Добавьте BOT_USERNAME=your_bot_username в .env файл")
logger.info("Starting RemnaWave Bot...")
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
logger.info(f"Bot Username: {self.config.BOT_USERNAME}")
# Initialize database
self.db = Database(self.config.DATABASE_URL)
await self._init_database()
# Initialize RemnaWave API
self.api = RemnaWaveAPI(
self.config.REMNAWAVE_URL,
self.config.REMNAWAVE_TOKEN,
self.config.SUBSCRIPTION_BASE_URL
)
logger.info("RemnaWave API initialized")
# Test API connection (optional - don't fail if it doesn't work)
await self._test_api_connection()
# Initialize bot and dispatcher
self.bot = Bot(
token=self.config.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Test bot token
await self._test_bot_token()
# Initialize dispatcher
self._setup_dispatcher()
# Initialize subscription monitor service
await self._init_monitor_service()
async def _init_database(self):
"""Initialize database with retry logic"""
max_retries = 3
for attempt in range(max_retries):
try:
await self.db.init_db()
logger.info("Database initialized successfully")
break
except Exception as e:
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
if attempt == max_retries - 1:
logger.error("Failed to initialize database after all retries")
raise
await asyncio.sleep(2) # Wait before retry
async def _test_api_connection(self):
"""Test API connection"""
try:
admin_ids = [int(x.strip()) for x in admin_ids_str.split(',') if x.strip()]
except ValueError:
admin_ids = []
# Get subscription base URL -
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
if not subscription_base_url:
subscription_base_url = 'https://sub.fring.tech'
return Config(
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
REMNAWAVE_MODE=os.getenv('REMNAWAVE_MODE', 'local'),
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
ADMIN_IDS=admin_ids,
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
system_stats = await self.api.get_system_stats()
if system_stats:
logger.info("RemnaWave API connection successful")
else:
logger.warning("RemnaWave API connection test failed - continuing anyway")
except Exception as e:
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
async def _test_bot_token(self):
"""Test bot token before starting"""
try:
bot_info = await self.bot.get_me()
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
# АВТОМАТИЧЕСКИ обновляем BOT_USERNAME если он не был установлен
if not self.config.BOT_USERNAME and bot_info.username:
self.config.BOT_USERNAME = bot_info.username
logger.info(f"✅ BOT_USERNAME автоматически установлен: {bot_info.username}")
print("💡 Добавьте BOT_USERNAME в .env файл для постоянного сохранения")
except Exception as e:
logger.error(f"Invalid bot token or network error: {e}")
raise
def _setup_dispatcher(self):
"""Setup dispatcher with middlewares and routers"""
storage = MemoryStorage()
self.dp = Dispatcher(storage=storage)
# Subscription URL
SUBSCRIPTION_BASE_URL=subscription_base_url,
# Store config, api, db, and monitor_service in dispatcher workflow_data for access in handlers
self.dp.workflow_data.update({
"config": self.config,
"api": self.api,
"db": self.db,
"monitor_service": None # Will be updated after monitor service is created
})
# Trial subscription settings
TRIAL_ENABLED=os.getenv('TRIAL_ENABLED', 'true').lower() == 'true',
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
# Setup middlewares in correct order
self.dp.message.middleware(LoggingMiddleware())
self.dp.callback_query.middleware(LoggingMiddleware())
# Monitor service settings
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
)
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
self.dp.message.middleware(WorkflowDataMiddleware())
self.dp.callback_query.middleware(WorkflowDataMiddleware())
self.dp.message.middleware(BotMiddleware(self.bot))
self.dp.callback_query.middleware(BotMiddleware(self.bot))
self.dp.message.middleware(DatabaseMiddleware(self.db))
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
self.dp.message.middleware(UserMiddleware(self.db, self.config))
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
# Register routers
self.dp.include_router(router)
self.dp.include_router(admin_router)
async def _init_monitor_service(self):
"""Initialize subscription monitor service"""
try:
self.monitor_service = await create_subscription_monitor(
self.bot, self.db, self.config, self.api
)
# Update workflow_data with monitor service
self.dp.workflow_data["monitor_service"] = self.monitor_service
# Start the monitor service
await self.monitor_service.start()
logger.info("Subscription monitor service started successfully")
except Exception as e:
logger.error(f"Failed to initialize monitor service: {e}")
# Don't fail the entire application if monitor service fails
logger.warning("Continuing without monitor service")
self.monitor_service = None
async def start(self):
"""Start bot polling"""
logger.info("Bot polling started successfully")
# ФИНАЛЬНАЯ ПРОВЕРКА конфигурации
if self.config.BOT_USERNAME:
logger.info(f"🎁 Реферальная система активна! Ссылки: https://t.me/{self.config.BOT_USERNAME}?start=ref_USERID")
else:
logger.warning("⚠️ Реферальная система неактивна! Установите BOT_USERNAME")
try:
await self.dp.start_polling(self.bot)
except Exception as e:
logger.error(f"Error during polling: {e}")
raise
finally:
await self.shutdown()
async def shutdown(self):
"""Shutdown all services"""
logger.info("Shutting down bot...")
# Stop monitor service first
if self.monitor_service:
try:
await self.monitor_service.stop()
logger.info("Monitor service stopped")
except Exception as e:
logger.error(f"Error stopping monitor service: {e}")
# Close API connection
if self.api:
try:
await self.api.close()
logger.info("API connection closed")
except Exception as e:
logger.error(f"Error closing API: {e}")
# Close database connection
if self.db:
try:
await self.db.close()
logger.info("Database connection closed")
except Exception as e:
logger.error(f"Error closing database: {e}")
# Close bot session
if self.bot:
try:
await self.bot.session.close()
logger.info("Bot session closed")
except Exception as e:
logger.error(f"Error closing bot session: {e}")
logger.info("Bot shutdown complete")
async def main():
"""Main function"""
app = None
try:
app = BotApplication()
await app.initialize()
await app.start()
except KeyboardInterrupt:
logger.info("Bot stopped by user (Ctrl+C)")
except Exception as e:
logger.error(f"Critical error in main: {e}")
import traceback
traceback.print_exc()
raise
finally:
if app:
await app.shutdown()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Bot stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
+5 -5
View File
@@ -23,7 +23,7 @@ class DatabaseMiddleware(BaseMiddleware):
return await handler(event, data)
class UserMiddleware(BaseMiddleware):
"""Middleware for user management"""
"""Middleware for user management - ИСПРАВЛЕНО: правильная инициализация языка"""
def __init__(self, db: Database, config: Config):
self.db = db
@@ -46,16 +46,16 @@ class UserMiddleware(BaseMiddleware):
# Check if user is admin
is_admin = telegram_user.id in self.config.ADMIN_IDS
# Create new user
# ИСПРАВЛЕНО: Создаем пользователя с пустым языком для первоначальной настройки
user = await self.db.create_user(
telegram_id=telegram_user.id,
username=telegram_user.username,
first_name=telegram_user.first_name,
last_name=telegram_user.last_name,
language=self.config.DEFAULT_LANGUAGE,
language='', # ИЗМЕНЕНО: пустой язык для нового пользователя
is_admin=is_admin
)
logger.info(f"Created new user: {telegram_user.id}")
logger.info(f"Created new user: {telegram_user.id} without language")
else:
# Update user info if changed
updated = False
@@ -79,7 +79,7 @@ class UserMiddleware(BaseMiddleware):
await self.db.update_user(user)
data['user'] = user
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
data['lang'] = user.language if user.language and user.language != '' else self.config.DEFAULT_LANGUAGE
except Exception as e:
logger.error(f"Error in UserMiddleware: {e}")
+994 -235
View File
File diff suppressed because it is too large Load Diff
+224 -418
View File
@@ -1,503 +1,309 @@
"""
Subscription Monitor Service
Сервис для мониторинга подписок, уведомлений пользователей и предложений продления
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from dataclasses import dataclass
import traceback
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from database import Database, UserSubscription, Subscription, User
from typing import Optional, List
from database import Database
from remnawave_api import RemnaWaveAPI
from translations import t
from keyboards import extend_subscription_keyboard, main_menu_keyboard
from utils import format_datetime, log_user_action
from config import Config
# Настройка логирования
logger = logging.getLogger(__name__)
@dataclass
class NotificationResult:
"""Результат отправки уведомления"""
success: bool
user_id: int
message: str
error: Optional[str] = None
class SubscriptionMonitorService:
"""Сервис мониторинга подписок"""
"""Service for monitoring subscriptions and sending notifications"""
def __init__(self, bot: Bot, db: Database, config: Config, api: Optional[RemnaWaveAPI] = None):
def __init__(self, bot, db: Database, config, api: RemnaWaveAPI = None):
self.bot = bot
self.db = db
self.config = config
self.api = api
self.is_running = False
self._monitor_task: Optional[asyncio.Task] = None
# Настройки уведомлений из конфига
self.WARNING_DAYS = config.MONITOR_WARNING_DAYS # За сколько дней предупреждать
self.CHECK_INTERVAL = config.MONITOR_CHECK_INTERVAL # Интервал проверки (в секундах)
self.DAILY_CHECK_HOUR = config.MONITOR_DAILY_CHECK_HOUR # В какой час дня делать основную
self._monitor_task = None
self._daily_task = None
async def start(self):
"""Запуск сервиса мониторинга"""
"""Start the monitoring service"""
if self.is_running:
logger.warning("Subscription monitor service is already running")
logger.warning("Monitor service is already running")
return
self.is_running = True
# Start periodic monitoring task
self._monitor_task = asyncio.create_task(self._monitor_loop())
# Start daily cleanup task
self._daily_task = asyncio.create_task(self._daily_loop())
logger.info("Subscription monitor service started")
async def stop(self):
"""Остановка сервиса мониторинга"""
"""Stop the monitoring service"""
if not self.is_running:
return
self.is_running = False
# Cancel tasks
if self._monitor_task:
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
if self._daily_task:
self._daily_task.cancel()
try:
await self._daily_task
except asyncio.CancelledError:
pass
logger.info("Subscription monitor service stopped")
async def _monitor_loop(self):
"""Основной цикл мониторинга"""
logger.info(f"Starting monitor loop with {self.CHECK_INTERVAL}s interval")
"""Main monitoring loop"""
while self.is_running:
try:
current_time = datetime.utcnow()
# Основная проверка раз в день в определенное время
if current_time.hour == self.DAILY_CHECK_HOUR:
await self._daily_check()
# Дополнительная проверка каждый час для критических случаев
await self._hourly_check()
# Ожидание до следующей проверки
await asyncio.sleep(self.CHECK_INTERVAL)
await self._check_expiring_subscriptions()
await asyncio.sleep(self.config.MONITOR_CHECK_INTERVAL)
except asyncio.CancelledError:
logger.info("Monitor loop cancelled")
break
except Exception as e:
logger.error(f"Error in monitor loop: {e}")
logger.error(traceback.format_exc())
# Продолжаем работу даже при ошибках
await asyncio.sleep(60) # Короткая пауза при ошибке
await asyncio.sleep(60) # Wait 1 minute before retry
async def _daily_check(self):
"""Ежедневная проверка всех подписок"""
logger.info("Starting daily subscription check")
async def _daily_loop(self):
"""Daily cleanup loop"""
while self.is_running:
try:
now = datetime.now()
# Wait until the specified hour
if now.hour == self.config.MONITOR_DAILY_CHECK_HOUR:
await self.force_daily_check()
# Wait until next day
tomorrow = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0) + timedelta(days=1)
sleep_time = (tomorrow - now).total_seconds()
else:
# Calculate time until next check
target_time = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0)
if target_time < now:
target_time += timedelta(days=1)
sleep_time = (target_time - now).total_seconds()
await asyncio.sleep(min(sleep_time, 3600)) # Check at least every hour
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in daily loop: {e}")
await asyncio.sleep(3600) # Wait 1 hour before retry
async def _check_expiring_subscriptions(self):
"""Check for expiring subscriptions and send warnings"""
try:
# Получаем все активные подписки пользователей
# Get all users
all_users = await self.db.get_all_users()
total_notifications = 0
for user in all_users:
try:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
active_subs = [sub for sub in user_subs if sub.is_active]
# Get expiring subscriptions for this user
expiring_subs = await self.db.get_expiring_subscriptions(
user.telegram_id,
self.config.MONITOR_WARNING_DAYS
)
for user_sub in active_subs:
# Проверяем каждую подписку
notification_sent = await self._check_and_notify_subscription(user, user_sub)
if notification_sent:
total_notifications += 1
# Небольшая пауза между уведомлениями
await asyncio.sleep(0.1)
for user_sub in expiring_subs:
await self._send_expiry_warning(user, user_sub)
except Exception as e:
logger.error(f"Error checking subscriptions for user {user.telegram_id}: {e}")
continue
logger.info(f"Daily check completed. Sent {total_notifications} notifications")
except Exception as e:
logger.error(f"Error in daily check: {e}")
logger.error(traceback.format_exc())
async def _hourly_check(self):
"""Часовая проверка критических подписок (истекают сегодня)"""
try:
now = datetime.utcnow()
tomorrow = now + timedelta(days=1)
# Получаем подписки, которые истекают в ближайшие 24 часа
all_users = await self.db.get_all_users()
for user in all_users:
try:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
if (user_sub.is_active and
user_sub.expires_at <= tomorrow and
user_sub.expires_at > now):
await self._check_and_notify_subscription(user, user_sub, urgent=True)
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"Error in hourly check for user {user.telegram_id}: {e}")
continue
except Exception as e:
logger.error(f"Error in hourly check: {e}")
logger.error(f"Error in check_expiring_subscriptions: {e}")
async def _check_and_notify_subscription(self, user: User, user_sub: UserSubscription, urgent: bool = False) -> bool:
"""
Проверить подписку и отправить уведомление если нужно
Returns: True если уведомление было отправлено
"""
async def _send_expiry_warning(self, user, user_subscription):
"""Send expiry warning to user"""
try:
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
days_left = (user_subscription.expires_at - datetime.utcnow()).days
# Получаем информацию о подписке
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
subscription = await self.db.get_subscription_by_id(user_subscription.subscription_id)
if not subscription:
logger.warning(f"Subscription {user_sub.subscription_id} not found")
return False
return
notification_type = None
# Don't send warnings for trial subscriptions
if subscription.is_trial:
return
# Определяем тип уведомления
if user_sub.expires_at <= now:
# Подписка истекла
notification_type = "expired"
elif days_until_expiry <= 0 and hours_until_expiry <= 24:
# Истекает сегодня
notification_type = "expires_today"
elif days_until_expiry == 1:
# Истекает завтра
notification_type = "expires_tomorrow"
elif days_until_expiry == self.WARNING_DAYS:
# Предупреждение за 2 дня
notification_type = "warning"
elif urgent and days_until_expiry <= 1:
# Срочное уведомление
notification_type = "urgent"
message = self._format_expiry_message(subscription.name, days_left, user.language)
if notification_type:
return await self._send_notification(user, user_sub, subscription, notification_type)
return False
await self.bot.send_message(user.telegram_id, message)
logger.info(f"Sent expiry warning to user {user.telegram_id} for subscription {subscription.name}")
except Exception as e:
logger.error(f"Error checking subscription {user_sub.id}: {e}")
return False
logger.error(f"Error sending expiry warning: {e}")
async def _send_notification(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> bool:
"""Отправить уведомление пользователю"""
try:
# Проверяем, не является ли подписка тестовой (для тестовых другая логика)
if subscription.is_trial and notification_type in ["warning", "expires_tomorrow"]:
# Для тестовых подписок не предлагаем продление
return await self._send_trial_expiry_notification(user, user_sub, subscription, notification_type)
# Формируем текст уведомления
message_text = self._format_notification_message(user, user_sub, subscription, notification_type)
# Формируем клавиатуру
keyboard = self._create_notification_keyboard(user, user_sub, subscription, notification_type)
# Отправляем уведомление
await self.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='Markdown'
)
# Логируем действие
log_user_action(user.telegram_id, f"notification_sent_{notification_type}", f"Sub: {subscription.name}")
logger.info(f"Sent {notification_type} notification to user {user.telegram_id} for subscription {subscription.name}")
return True
except Exception as e:
logger.error(f"Error sending notification to user {user.telegram_id}: {e}")
return False
async def _send_trial_expiry_notification(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> bool:
"""Отправить уведомление об истечении тестовой подписки"""
try:
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
if notification_type == "expires_today" or hours_until_expiry <= 24:
message_text = (
f"⏰ *Ваша тестовая подписка истекает сегодня!*\n\n"
f"📋 Подписка: *{subscription.name}*\n"
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
f"💡 Чтобы продолжить пользоваться сервисом, приобретите полную подписку!"
)
elif notification_type == "expires_tomorrow" or days_until_expiry == 1:
message_text = (
f"⚠️ *Ваша тестовая подписка истекает завтра!*\n\n"
f"📋 Подписка: *{subscription.name}*\n"
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n\n"
f"💡 Не забудьте приобрести полную подписку, чтобы продолжить пользоваться сервисом!"
)
def _format_expiry_message(self, subscription_name: str, days_left: int, language: str = 'ru') -> str:
"""Format expiry warning message"""
if language == 'ru':
if days_left <= 0:
return f"⚠️ Ваша подписка '{subscription_name}' истекла!\n\nДля продления перейдите в раздел 'Мои подписки'."
elif days_left == 1:
return f"⚠️ Ваша подписка '{subscription_name}' истекает завтра!\n\nНе забудьте продлить её в разделе 'Мои подписки'."
else:
return False
# Клавиатура для тестовой подписки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="💳 Купить подписку",
callback_data="buy_subscription"
)],
[InlineKeyboardButton(
text="💰 Пополнить баланс",
callback_data="topup_balance"
)],
[InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="main_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='Markdown'
)
log_user_action(user.telegram_id, f"trial_notification_sent_{notification_type}", f"Sub: {subscription.name}")
logger.info(f"Sent trial {notification_type} notification to user {user.telegram_id}")
return True
except Exception as e:
logger.error(f"Error sending trial notification to user {user.telegram_id}: {e}")
return False
def _format_notification_message(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> str:
"""Форматировать текст уведомления"""
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
base_info = (
f"📋 Подписка: *{subscription.name}*\n"
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n"
f"💰 Цена продления: *{subscription.price} руб.*"
)
if notification_type == "expired":
return (
f"❌ *Ваша подписка истекла!*\n\n"
f"{base_info}\n\n"
f"🔄 Продлите подписку, чтобы продолжить пользоваться сервисом."
)
elif notification_type == "expires_today" or notification_type == "urgent":
return (
f"⏰ *Ваша подписка истекает сегодня!*\n\n"
f"{base_info}\n"
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
f"🔄 Продлите подписку прямо сейчас!"
)
elif notification_type == "expires_tomorrow":
return (
f"⚠️ *Ваша подписка истекает завтра!*\n\n"
f"{base_info}\n\n"
f"🔄 Рекомендуем продлить подписку заранее."
)
elif notification_type == "warning":
return (
f"📢 *Напоминание о подписке*\n\n"
f"{base_info}\n"
f"⏳ Осталось: *{days_until_expiry} дней*\n\n"
f"💡 Не забудьте продлить подписку вовремя!"
)
return f"⚠️ Ваша подписка '{subscription_name}' истекает через {days_left} дн.!\n\nРекомендуем продлить её заранее в разделе 'Мои подписки'."
else:
return f"🔔 Уведомление о подписке *{subscription.name}*"
def _create_notification_keyboard(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> InlineKeyboardMarkup:
"""Создать клавиатуру для уведомления"""
buttons = []
# Кнопка продления (только для не-тестовых подписок)
if not subscription.is_trial:
if user.balance >= subscription.price:
buttons.append([InlineKeyboardButton(
text=f"🔄 Продлить за {subscription.price} руб.",
callback_data=f"extend_sub_{user_sub.id}"
)])
if days_left <= 0:
return f"⚠️ Your subscription '{subscription_name}' has expired!\n\nTo renew, go to 'My Subscriptions'."
elif days_left == 1:
return f"⚠️ Your subscription '{subscription_name}' expires tomorrow!\n\nDon't forget to renew it in 'My Subscriptions'."
else:
buttons.append([InlineKeyboardButton(
text=f"💰 Пополнить баланс (нужно {subscription.price - user.balance} руб.)",
callback_data="topup_balance"
)])
# Кнопка покупки новой подписки
buttons.append([InlineKeyboardButton(
text="💳 Купить подписку",
callback_data="buy_subscription"
)])
# Кнопка "Мои подписки"
buttons.append([InlineKeyboardButton(
text="📋 Мои подписки",
callback_data="my_subscriptions"
)])
# Кнопка главного меню
buttons.append([InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="main_menu"
)])
return InlineKeyboardMarkup(inline_keyboard=buttons)
async def check_single_user(self, user_id: int) -> List[NotificationResult]:
"""Проверить подписки конкретного пользователя (для тестирования)"""
results = []
try:
user = await self.db.get_user_by_telegram_id(user_id)
if not user:
return [NotificationResult(False, user_id, "User not found")]
user_subs = await self.db.get_user_subscriptions(user_id)
for user_sub in user_subs:
if user_sub.is_active:
try:
sent = await self._check_and_notify_subscription(user, user_sub)
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
sub_name = subscription.name if subscription else "Unknown"
results.append(NotificationResult(
success=sent,
user_id=user_id,
message=f"Subscription: {sub_name}, Sent: {sent}"
))
except Exception as e:
results.append(NotificationResult(
success=False,
user_id=user_id,
message=f"Error checking subscription {user_sub.id}",
error=str(e)
))
except Exception as e:
results.append(NotificationResult(
success=False,
user_id=user_id,
message="Error checking user",
error=str(e)
))
return results
async def get_service_status(self) -> dict:
"""Получить статус сервиса"""
return {
"is_running": self.is_running,
"check_interval": self.CHECK_INTERVAL,
"daily_check_hour": self.DAILY_CHECK_HOUR,
"warning_days": self.WARNING_DAYS,
"last_check": datetime.utcnow().isoformat() if self.is_running else None
}
return f"⚠️ Your subscription '{subscription_name}' expires in {days_left} days!\n\nWe recommend renewing it in advance in 'My Subscriptions'."
async def force_daily_check(self):
"""Принудительно запустить ежедневную проверку"""
logger.info("Force starting daily check")
await self._daily_check()
async def deactivate_expired_subscriptions(self):
"""Деактивировать истекшие подписки"""
"""Force daily check and cleanup"""
try:
now = datetime.utcnow()
logger.info("Starting daily check and cleanup")
# Deactivate expired subscriptions
deactivated_count = await self.deactivate_expired_subscriptions()
# Send final expiry notifications
await self._send_final_expiry_notifications()
logger.info(f"Daily check completed. Deactivated {deactivated_count} expired subscriptions")
except Exception as e:
logger.error(f"Error in force_daily_check: {e}")
async def deactivate_expired_subscriptions(self) -> int:
"""Deactivate expired subscriptions"""
try:
count = 0
all_users = await self.db.get_all_users()
deactivated_count = 0
for user in all_users:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
if user_sub.is_active and user_sub.expires_at <= now:
# Деактивируем подписку
if user_sub.is_active and user_sub.expires_at < datetime.utcnow():
user_sub.is_active = False
await self.db.update_user_subscription(user_sub)
count += 1
# Деактивируем в RemnaWave если API доступно
# Also try to deactivate in RemnaWave if possible
if self.api and user_sub.short_uuid:
try:
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
if remna_user_details:
user_uuid = remna_user_details.get('uuid')
if user_uuid:
# Блокируем пользователя в RemnaWave
await self.api.update_user(user_uuid, {"enable": False})
logger.info(f"Disabled user {user_uuid} in RemnaWave")
user_data = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
if user_data and user_data.get('uuid'):
await self.api.update_user(user_data['uuid'], {'status': 'EXPIRED'})
except Exception as e:
logger.error(f"Failed to disable user in RemnaWave: {e}")
deactivated_count += 1
log_user_action(user.telegram_id, "subscription_expired", f"SubID: {user_sub.id}")
logger.info(f"Deactivated {deactivated_count} expired subscriptions")
return deactivated_count
logger.warning(f"Could not deactivate user in RemnaWave: {e}")
return count
except Exception as e:
logger.error(f"Error deactivating expired subscriptions: {e}")
return 0
async def _send_final_expiry_notifications(self):
"""Send final notifications for just-expired subscriptions"""
try:
# Get subscriptions that expired today
all_users = await self.db.get_all_users()
for user in all_users:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
# Check if subscription expired today (within last 24 hours)
time_since_expiry = datetime.utcnow() - user_sub.expires_at
if (time_since_expiry.total_seconds() > 0 and
time_since_expiry.total_seconds() <= 86400): # 24 hours
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
if subscription and not subscription.is_trial:
message = self._format_expiry_message(subscription.name, 0, user.language)
await self.bot.send_message(user.telegram_id, message)
except Exception as e:
logger.error(f"Error sending final expiry notifications: {e}")
async def check_single_user(self, user_id: int):
"""Check subscriptions for a single user (for testing)"""
try:
results = []
user = await self.db.get_user_by_telegram_id(user_id)
if not user:
results.append({
'success': False,
'message': f'User {user_id} not found',
'error': None
})
return results
# Get user subscriptions
user_subs = await self.db.get_user_subscriptions(user_id)
if not user_subs:
results.append({
'success': True,
'message': f'User {user_id} has no subscriptions',
'error': None
})
return results
for user_sub in user_subs:
try:
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
days_left = (user_sub.expires_at - datetime.utcnow()).days
if days_left <= self.config.MONITOR_WARNING_DAYS:
# Send test notification
message = self._format_expiry_message(subscription.name, days_left, user.language)
await self.bot.send_message(user_id, f"[ТЕСТ] {message}")
results.append({
'success': True,
'message': f'Sent warning for subscription "{subscription.name}" (expires in {days_left} days)',
'error': None
})
else:
results.append({
'success': True,
'message': f'Subscription "{subscription.name}" is OK (expires in {days_left} days)',
'error': None
})
except Exception as e:
results.append({
'success': False,
'message': f'Error checking subscription ID {user_sub.id}',
'error': str(e)
})
return results
except Exception as e:
return [{
'success': False,
'message': f'Error checking user {user_id}',
'error': str(e)
}]
async def get_service_status(self) -> dict:
"""Get service status information"""
return {
'is_running': self.is_running,
'check_interval': self.config.MONITOR_CHECK_INTERVAL,
'daily_check_hour': self.config.MONITOR_DAILY_CHECK_HOUR,
'warning_days': self.config.MONITOR_WARNING_DAYS,
'last_check': datetime.now().strftime("%Y-%m-%d %H:%M:%S") if self.is_running else None
}
# Функция для инициализации и запуска сервиса
async def create_subscription_monitor(bot: Bot, db: Database, config: Config,
api: Optional[RemnaWaveAPI] = None) -> SubscriptionMonitorService:
"""Создать и настроить сервис мониторинга подписок"""
service = SubscriptionMonitorService(bot, db, config, api)
return service
# Пример использования в основном файле бота
"""
from subscription_monitor import create_subscription_monitor
async def main():
# Инициализация бота, базы данных, конфига
bot = Bot(token=config.BOT_TOKEN)
db = Database(config.DATABASE_URL)
api = RemnaWaveAPI(config.REMNAWAVE_API_URL, config.REMNAWAVE_API_KEY)
# Создание и запуск сервиса мониторинга
monitor_service = await create_subscription_monitor(bot, db, config, api)
await monitor_service.start()
try:
# Запуск бота
await dp.start_polling(bot)
finally:
# Остановка сервиса при завершении
await monitor_service.stop()
if __name__ == "__main__":
asyncio.run(main())
"""
async def create_subscription_monitor(bot, db: Database, config, api: RemnaWaveAPI = None) -> SubscriptionMonitorService:
"""Create and return subscription monitor service"""
return SubscriptionMonitorService(bot, db, config, api)
+54 -2
View File
@@ -16,7 +16,9 @@ TRANSLATIONS = {
'trial_not_available': '❌ Тестовая подписка недоступна',
'trial_success': '🎉 Тестовая подписка успешно активирована!\n\nТеперь вы можете найти её в разделе "Мои подписки".',
'trial_error': '❌ Ошибка при создании тестовой подписки',
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nТариф действует 3 дня!\n\nОграничение трафика - 2гб!',
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nНа тарифе действует ограничение в 3 дня\n\nОграничение по трафику - 2гб',
'subscriptions_list': '📋 Список подписок в продаже:',
# Balance menu
'your_balance': '💰 Ваш баланс: {balance:.2f} руб.',
@@ -25,6 +27,17 @@ TRANSLATIONS = {
'topup_card': 'Пополнение картой',
'topup_support': 'Через саппорт',
'back': 'Назад',
'system_management': 'Управление системой',
'nodes_management': 'Управление нодами',
'system_users': 'Системные пользователи',
'system_statistics': 'Системная статистика',
'restart_nodes': 'Перезагрузить ноды',
'bulk_operations': 'Массовые операции',
'search_user': 'Поиск пользователя',
'user_details': 'Детали пользователя',
'reset_traffic': 'Сбросить трафик',
'disable_user': 'Отключить пользователя',
'enable_user': 'Включить пользователя',
'send_message': 'Отправить сообщение',
'send_to_user': 'Отправить пользователю',
@@ -51,6 +64,7 @@ TRANSLATIONS = {
'subscription_expires': 'Истекает: {date}',
'subscription_expired': 'Истекла',
'subscription_active': 'Активна до: {date}',
'your_subscriptions': '👀 Список ваших подписок:',
# Payments
'payment_created': '✅ Заявка на пополнение создана.\nОбратитесь к @{support} для оплаты.',
@@ -67,6 +81,20 @@ TRANSLATIONS = {
'promocode_expired': '❌ Промокод истек',
'promocode_used': '❌ Промокод уже использован',
'promocode_limit': '❌ Лимит использований исчерпан',
'referral_program': 'Реферальная программа',
'referral_conditions': 'Условия программы',
'referral_link': 'Ваша реферальная ссылка',
'referral_code': 'Ваш промокод',
'referral_stats': 'Статистика',
'total_referrals': 'Всего приглашено',
'active_referrals': 'Активных рефералов',
'total_earned': 'Заработано',
'my_referrals': 'Мои рефералы',
'referral_reward_referrer': 'За каждого приглашенного: 150₽',
'referral_reward_referred': 'Бонус новичку: 150₽ после пополнения на 300₽',
'referral_percentage': 'Процент с платежей: 25%',
'no_referrals': 'У вас пока нет рефералов',
'referral_welcome': 'Добро пожаловать по реферальной ссылке!',
# Purchase
'insufficient_balance': '❌ Недостаточно средств на балансе',
@@ -145,7 +173,17 @@ TRANSLATIONS = {
'enter_user_id_message': 'Enter user id message',
'enter_message_text': 'Enter message text',
'trial_subscription': 'Trial subscription',
'system_management': 'System Management',
'nodes_management': 'Nodes Management',
'system_users': 'System Users',
'system_statistics': 'System Statistics',
'restart_nodes': 'Restart Nodes',
'bulk_operations': 'Bulk Operations',
'search_user': 'Search User',
'user_details': 'User Details',
'reset_traffic': 'Reset Traffic',
'disable_user': 'Disable User',
'enable_user': 'Enable User',
# Balance menu
'your_balance': '💰 Your balance: ${balance:.2f}',
@@ -173,6 +211,20 @@ TRANSLATIONS = {
'payment_card_info': '💳 For card payment contact @{support}',
'no_payments': '❌ Payment history is empty',
'payment_item': '{date}: ${amount:.2f} - {description} ({status})',
'referral_program': 'Referral Program',
'referral_conditions': 'Program Conditions',
'referral_link': 'Your referral link',
'referral_code': 'Your promo code',
'referral_stats': 'Statistics',
'total_referrals': 'Total invited',
'active_referrals': 'Active referrals',
'total_earned': 'Total earned',
'my_referrals': 'My referrals',
'referral_reward_referrer': 'For each invited: $150',
'referral_reward_referred': 'Newbie bonus: $150 after topping up $300',
'referral_percentage': 'Percentage from payments: 25%',
'no_referrals': 'You have no referrals yet',
'referral_welcome': 'Welcome via referral link!',
# Promocodes
'enter_promocode': '🎁 Enter promocode:',
+503 -229
View File
@@ -1,191 +1,131 @@
import re
import uuid
import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
import logging
from typing import Tuple, Optional, Dict, Any
from database import Database
from database import ReferralProgram, ReferralEarning # ДОБАВЛЕНО: импорт моделей
logger = logging.getLogger(__name__)
def generate_username() -> str:
"""Generate random username for RemnaWave"""
return f"user_{secrets.token_hex(8)}"
def generate_password() -> str:
"""Generate random password"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(12))
def generate_promocode() -> str:
"""Generate random promocode"""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
def is_valid_email(email: str) -> bool:
"""Validate email format"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def is_valid_amount(amount_str: str) -> Tuple[bool, float]:
"""Validate and parse amount"""
def is_valid_amount(text: str) -> Tuple[bool, float]:
"""Validate amount input"""
try:
amount = float(amount_str.replace(',', '.'))
# Remove spaces and replace comma with dot
text = text.strip().replace(' ', '').replace(',', '.')
amount = float(text)
# Check if amount is positive and reasonable
if amount <= 0:
return False, 0
if amount > 100000: # Max amount limit
return False, 0
return False, 0.0
if amount > 1000000: # Max 1M rubles
return False, 0.0
# Round to 2 decimal places
amount = round(amount, 2)
return True, amount
except ValueError:
return False, 0
def format_date(date: datetime, lang: str = 'ru') -> str:
"""Format date for display"""
if lang == 'ru':
months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
]
return f"{date.day} {months[date.month-1]} {date.year}"
else:
return date.strftime("%B %d, %Y")
def format_datetime(date: datetime, lang: str = 'ru') -> str:
"""Format datetime for display"""
if lang == 'ru':
return date.strftime("%d.%m.%Y %H:%M")
else:
return date.strftime("%m/%d/%Y %H:%M")
def calculate_expiry_date(days: int) -> str:
"""Calculate expiry date in ISO format"""
expiry = datetime.utcnow() + timedelta(days=days)
return expiry.isoformat() + 'Z'
def parse_telegram_id(text: str) -> Optional[int]:
"""Parse Telegram ID from text"""
try:
telegram_id = int(text.strip())
if telegram_id > 0:
return telegram_id
except ValueError:
pass
return None
def format_traffic(gb: int, lang: str = 'ru') -> str:
"""Format traffic limit for display"""
if gb == 0:
return "Безлимитный" if lang == 'ru' else "Unlimited"
else:
return f"{gb} ГБ" if lang == 'ru' else f"{gb} GB"
def paginate_list(items: List[Any], page: int, per_page: int = 10) -> Tuple[List[Any], int]:
"""Paginate list of items"""
total_pages = (len(items) + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
return items[start_idx:end_idx], total_pages
def escape_markdown(text: str) -> str:
"""Escape markdown special characters"""
special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
for char in special_chars:
text = text.replace(char, f'\\{char}')
return text
def truncate_text(text: str, max_length: int = 4000) -> str:
"""Truncate text to fit Telegram message limits"""
if len(text) <= max_length:
return text
return text[:max_length-3] + "..."
def validate_squad_uuid(uuid_str: str) -> bool:
"""Validate UUID format"""
try:
uuid.UUID(uuid_str)
return True
except ValueError:
return False
def format_subscription_info(subscription: Dict[str, Any], lang: str = 'ru') -> str:
"""Format subscription information for display"""
from translations import t
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
info = t('subscription_info', lang,
name=subscription['name'],
price=subscription['price'],
days=subscription['duration_days'],
traffic=traffic,
description=subscription.get('description', '')
)
return info
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
expires_at: datetime, lang: str = 'ru') -> str:
"""Format user subscription information"""
from translations import t
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
# Check if expired
now = datetime.utcnow()
if expires_at < now:
status = t('subscription_expired', lang)
else:
status = t('subscription_active', lang, date=format_date(expires_at, lang))
info = f"📋 {subscription['name']}\n"
info += f"{subscription['duration_days']} дней\n" if lang == 'ru' else f"{subscription['duration_days']} days\n"
info += f"📊 {traffic}\n"
info += f"🕒 {status}\n"
if subscription.get('description'):
info += f"\n{subscription['description']}"
return info
except (ValueError, TypeError):
return False, 0.0
def validate_promocode_format(code: str) -> bool:
"""Validate promocode format"""
if not code:
return False
# Remove spaces and convert to uppercase
code = code.strip().upper()
# Check length
if len(code) < 3 or len(code) > 20:
return False
if not re.match(r'^[A-Z0-9]+$', code.upper()):
# Check that code contains only letters and numbers
if not code.replace('_', '').isalnum():
return False
return True
def calculate_discount(original_price: float, promocode: Dict[str, Any]) -> float:
"""Calculate discount amount"""
if promocode.get('discount_percent'):
return original_price * (promocode['discount_percent'] / 100)
def validate_squad_uuid(uuid: str) -> bool:
"""Validate squad UUID format"""
if not uuid or not isinstance(uuid, str):
return False
uuid = uuid.strip()
# Basic UUID format validation
if len(uuid) < 8:
return False
# UUID should contain only hex characters and hyphens
allowed_chars = set('0123456789abcdefABCDEF-')
if not all(c in allowed_chars for c in uuid):
return False
return True
def parse_telegram_id(text: str) -> Optional[int]:
"""Parse Telegram ID from text"""
try:
# Remove spaces and common prefixes
text = text.strip().replace(' ', '')
# Remove common prefixes
if text.startswith('@'):
text = text[1:]
if text.startswith('id'):
text = text[2:]
# Try to convert to int
telegram_id = int(text)
# Validate that it's a reasonable Telegram ID
if telegram_id <= 0 or telegram_id > 9999999999: # Max 10 digits
return None
return telegram_id
except (ValueError, TypeError):
return None
def generate_username() -> str:
"""Generate random username for RemnaWave"""
prefix = "user_"
random_part = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(8))
return prefix + random_part
def generate_password() -> str:
"""Generate random password for RemnaWave"""
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
def calculate_expiry_date(days: int) -> str:
"""Calculate expiry date for subscription"""
expiry_date = datetime.now() + timedelta(days=days)
return expiry_date.isoformat() + 'Z'
def format_datetime(dt: datetime, language: str = 'ru') -> str:
"""Format datetime for display"""
if not dt:
return "N/A"
if language == 'ru':
return dt.strftime('%d.%m.%Y %H:%M')
else:
return min(promocode.get('discount_amount', 0), original_price)
return dt.strftime('%Y-%m-%d %H:%M')
def format_payment_status(status: str, lang: str = 'ru') -> str:
"""Format payment status for display"""
status_map = {
'pending': 'В ожидании' if lang == 'ru' else 'Pending',
'completed': 'Завершен' if lang == 'ru' else 'Completed',
'cancelled': 'Отменен' if lang == 'ru' else 'Cancelled',
'failed': 'Ошибка' if lang == 'ru' else 'Failed'
}
return status_map.get(status, status)
def clean_phone_number(phone: str) -> str:
"""Clean and format phone number"""
# Remove all non-digit characters
digits = re.sub(r'\D', '', phone)
def format_date(dt: datetime, language: str = 'ru') -> str:
"""Format date for display"""
if not dt:
return "N/A"
# Handle Russian phone numbers
if digits.startswith('8') and len(digits) == 11:
digits = '7' + digits[1:]
elif digits.startswith('9') and len(digits) == 10:
digits = '7' + digits
return digits
if language == 'ru':
return dt.strftime('%d.%m.%Y')
else:
return dt.strftime('%Y-%m-%d')
def format_bytes(bytes_value: int) -> str:
"""Format bytes to human readable format"""
@@ -200,76 +140,410 @@ def format_bytes(bytes_value: int) -> str:
value /= 1024
unit_index += 1
if unit_index == 0:
return f"{int(value)} {units[unit_index]}"
else:
if value >= 100:
return f"{value:.0f} {units[unit_index]}"
elif value >= 10:
return f"{value:.1f} {units[unit_index]}"
else:
return f"{value:.2f} {units[unit_index]}"
def get_subscription_connection_url(base_url: str, short_uuid: str) -> str:
"""Generate subscription connection URL"""
return f"{base_url.rstrip('/')}/api/sub/{short_uuid}"
def format_payment_status(status: str, language: str = 'ru') -> str:
"""Format payment status for display"""
status_map = {
'ru': {
'pending': 'Ожидает',
'completed': 'Завершен',
'cancelled': 'Отменен',
'failed': 'Ошибка'
},
'en': {
'pending': 'Pending',
'completed': 'Completed',
'cancelled': 'Cancelled',
'failed': 'Failed'
}
}
return status_map.get(language, status_map['ru']).get(status, status)
def format_subscription_info(subscription: Dict[str, Any], language: str = 'ru') -> str:
"""Format subscription information for display"""
text = ""
if language == 'ru':
text += f"📋 **Подписка: {subscription['name']}**\n\n"
text += f"💰 Цена: {subscription['price']} руб.\n"
text += f"⏱ Длительность: {subscription['duration_days']} дн.\n"
if subscription['traffic_limit_gb'] > 0:
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
else:
text += f"📊 Лимит трафика: Безлимит\n"
if subscription.get('description'):
text += f"\n📝 Описание:\n{subscription['description']}"
else:
text += f"📋 **Subscription: {subscription['name']}**\n\n"
text += f"💰 Price: ${subscription['price']}\n"
text += f"⏱ Duration: {subscription['duration_days']} days\n"
if subscription['traffic_limit_gb'] > 0:
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
else:
text += f"📊 Traffic limit: Unlimited\n"
if subscription.get('description'):
text += f"\n📝 Description:\n{subscription['description']}"
return text
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
expires_at: datetime, language: str = 'ru') -> str:
"""Format user subscription information for display - ИСПРАВЛЕНО: добавлены пометки для импортированных"""
text = ""
if language == 'ru':
text += f"📋 **{subscription['name']}**\n\n"
# Status
now = datetime.utcnow()
if expires_at < now:
status = "❌ Истекла"
days_left = 0
elif not user_sub.get('is_active', True):
status = "⏸ Приостановлена"
days_left = (expires_at - now).days
else:
days_left = (expires_at - now).days
status = f"✅ Активна"
text += f"🔘 Статус: {status}\n"
text += f"📅 Истекает: {format_datetime(expires_at, language)}\n"
if days_left > 0:
text += f"⏰ Осталось: {days_left} дн.\n"
# Traffic info
if subscription['traffic_limit_gb'] > 0:
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
else:
text += f"📊 Лимит трафика: Безлимит\n"
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'импорт' in subscription.get('description', '').lower()):
text += f"\n🔄 Тип: Импортированная из старой системы\n"
text += f"ℹ️ Продление недоступно"
# Description
if subscription.get('description') and not ('импорт' in subscription.get('description', '').lower()):
text += f"\n📝 {subscription['description']}"
else:
text += f"📋 **{subscription['name']}**\n\n"
# Status
now = datetime.utcnow()
if expires_at < now:
status = "❌ Expired"
days_left = 0
elif not user_sub.get('is_active', True):
status = "⏸ Suspended"
days_left = (expires_at - now).days
else:
days_left = (expires_at - now).days
status = f"✅ Active"
text += f"🔘 Status: {status}\n"
text += f"📅 Expires: {format_datetime(expires_at, language)}\n"
if days_left > 0:
text += f"⏰ Days left: {days_left}\n"
# Traffic info
if subscription['traffic_limit_gb'] > 0:
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
else:
text += f"📊 Traffic limit: Unlimited\n"
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'import' in subscription.get('description', '').lower()):
text += f"\n🔄 Type: Imported from old system\n"
text += f"️ Extension not available"
# Description
if subscription.get('description') and not ('import' in subscription.get('description', '').lower()):
text += f"\n📝 {subscription['description']}"
return text
def log_user_action(user_id: int, action: str, details: str = ""):
"""Log user action"""
logger.info(f"User {user_id} - {action}: {details}")
logger.info(f"USER_ACTION: {user_id} - {action}" + (f" - {details}" if details else ""))
def format_subscription_status(expires_at: datetime, lang: str = 'ru') -> str:
"""Format subscription status with emoji"""
now = datetime.utcnow()
async def process_referral_rewards(user_id: int, amount: float, payment_id: int, db: Database, bot=None):
"""Process referral rewards after successful payment - ИСПРАВЛЕНА СИГНАТУРА"""
try:
# Получаем конфигурацию из переменных окружения напрямую
import os
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
referred_bonus = float(os.getenv('REFERRAL_REFERRED_BONUS', '150.0'))
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
# Проверяем есть ли у пользователя реферер
referral = await db.get_referral_by_referred_id(user_id)
if not referral:
logger.debug(f"No referral found for user {user_id}")
return
# Получаем информацию о пользователе
user = await db.get_user_by_telegram_id(user_id)
if not user:
logger.error(f"User {user_id} not found")
return
logger.info(f"Processing referral rewards for user {user_id}, amount {amount}, referrer {referral.referrer_id}")
if not referral.first_reward_paid and user.balance >= threshold:
logger.info(f"Processing first reward for referral {referral.id} (threshold: {threshold}, reward: {first_reward})")
await db.add_balance(referral.referrer_id, first_reward)
# Создаем запись о платеже для реферера
await db.create_payment(
user_id=referral.referrer_id,
amount=first_reward,
payment_type='referral',
description=f'Первая награда за реферала ID:{user_id}',
status='completed'
)
# Выплачиваем первую награду рефереру (записываем в историю)
success = await db.create_referral_earning(
referrer_id=referral.referrer_id,
referred_id=user_id,
amount=first_reward,
earning_type='first_reward',
related_payment_id=payment_id
)
if success:
logger.info(f"First reward paid: {first_reward}₽ to referrer {referral.referrer_id}")
if bot:
try:
# Уведомляем реферера
await bot.send_message(
referral.referrer_id,
f"🎉 Поздравляем! Ваш реферал пополнил баланс на {threshold}₽+\n\n"
f"💰 Вам начислено {first_reward}₽ за приведенного друга!\n"
f"Теперь вы будете получать {percentage*100:.0f}% с каждого его следующего платежа."
)
# Уведомляем самого пользователя
await bot.send_message(
user_id,
f"🎁 Бонус активирован! Вам начислено {referred_bonus}₽ за переход по реферальной ссылке!"
)
# Добавляем бонус рефералу
await db.add_balance(user_id, referred_bonus)
await db.create_payment(
user_id=user_id,
amount=referred_bonus,
payment_type='referral',
description='Бонус за переход по реферальной ссылке',
status='completed'
)
logger.info(f"Referral bonus notifications sent and balance updated")
except Exception as e:
logger.error(f"Failed to send referral notifications: {e}")
else:
logger.error(f"Failed to create first reward earning")
if amount > 0 and referral.first_reward_paid: # ИЗМЕНЕНО: добавлено условие first_reward_paid
percentage_reward = amount * percentage
if percentage_reward >= 0.01: # Минимум 1 копейка
await db.add_balance(referral.referrer_id, percentage_reward)
# Создаем запись о платеже для реферера
await db.create_payment(
user_id=referral.referrer_id,
amount=percentage_reward,
payment_type='referral',
description=f'{percentage*100:.0f}% дохода от реферала ID:{user_id}',
status='completed'
)
success = await db.create_referral_earning(
referrer_id=referral.referrer_id,
referred_id=user_id,
amount=percentage_reward,
earning_type='percentage',
related_payment_id=payment_id
)
if success:
logger.info(f"Percentage reward paid: {percentage_reward:.2f}₽ ({percentage*100:.0f}%) to referrer {referral.referrer_id}")
if bot and percentage_reward >= 1.0: # Уведомляем только если сумма >= 1₽
try:
await bot.send_message(
referral.referrer_id,
f"💰 Реферальный доход!\n\n"
f"Ваш реферал совершил платеж на {amount:.2f}\n"
f"Вам начислено: {percentage_reward:.2f}₽ ({percentage*100:.0f}%)"
)
except Exception as e:
logger.error(f"Failed to send percentage notification: {e}")
else:
logger.error(f"Failed to create percentage earning")
elif amount > 0 and not referral.first_reward_paid:
logger.info(f"Skipping percentage reward for user {user_id} - first reward not yet paid")
if expires_at < now:
return "❌ Истекла" if lang == 'ru' else "❌ Expired"
except Exception as e:
logger.error(f"Error processing referral rewards: {e}")
async def create_referral_from_start_param(user_telegram_id: int, start_param: str, db: Database, bot=None):
"""Create referral relationship from start parameter"""
try:
if not start_param.startswith("ref_"):
return False
referrer_id = int(start_param.replace("ref_", ""))
# Проверяем что это не тот же пользователь
if referrer_id == user_telegram_id:
logger.warning(f"User {user_telegram_id} tried to refer themselves")
return False
# Проверяем что у пользователя еще нет реферера
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
if existing_referral:
logger.info(f"User {user_telegram_id} already has referrer")
return False
# Генерируем промокод реферера
referral_code = await db.generate_unique_referral_code(referrer_id)
# Создаем реферальную связь
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
if referral:
logger.info(f"Created referral: {referrer_id} -> {user_telegram_id} with code {referral_code}")
# Уведомляем реферера
if bot:
try:
referrer = await db.get_user_by_telegram_id(referrer_id)
if referrer:
await bot.send_message(
referrer_id,
f"🎉 Отлично! По вашей ссылке зарегистрировался новый пользователь!\n\n"
f"Вы получите 150₽ после того, как он пополнит баланс на 300₽.\n"
f"И будете получать 25% с каждого его платежа!"
)
except Exception as e:
logger.error(f"Failed to notify referrer: {e}")
return True
return False
except (ValueError, TypeError) as e:
logger.warning(f"Invalid referral parameter: {start_param}")
return False
except Exception as e:
logger.error(f"Error creating referral from start param: {e}")
return False
async def create_referral_from_promocode(user_telegram_id: int, referral_code: str, db: Database, bot=None):
"""Create referral relationship from promocode - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
try:
if not referral_code.startswith("REF"):
return False
logger.info(f"Trying to use referral code {referral_code} for user {user_telegram_id}")
# Ищем реферера по коду - ИСПРАВЛЕНО: используем правильный импорт
async with db.session_factory() as session:
from sqlalchemy import select
result = await session.execute(
select(ReferralProgram).where(ReferralProgram.referral_code == referral_code)
)
referral_record = result.scalar_one_or_none()
if not referral_record:
logger.warning(f"No referrer found for code {referral_code}")
return False
referrer_id = referral_record.referrer_id
# Проверяем что пользователь не пытается использовать свой код
if referrer_id == user_telegram_id:
logger.warning(f"User {user_telegram_id} tried to use own referral code")
return False
# Проверяем что у пользователя еще нет реферера
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
if existing_referral:
logger.info(f"User {user_telegram_id} already has referrer")
return False
# Создаем реферальную связь
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
if referral:
logger.info(f"Created referral from promocode: {referrer_id} -> {user_telegram_id}")
# Уведомляем реферера
if bot:
try:
await bot.send_message(
referrer_id,
f"🎉 По вашему промокоду {referral_code} зарегистрировался новый пользователь!\n\n"
f"Вы получите 150₽ после того, как он пополнит баланс на 300₽."
)
except Exception as e:
logger.error(f"Failed to notify referrer: {e}")
return True
return False
except Exception as e:
logger.error(f"Error creating referral from promocode: {e}")
return False
def generate_referral_link(bot_username: str, user_id: int) -> str:
"""Generate referral link for user"""
if not bot_username:
return ""
days_left = (expires_at - now).days
if bot_username.startswith('@'):
bot_username = bot_username[1:]
if days_left == 0:
return "⚠️ Истекает сегодня" if lang == 'ru' else "⚠️ Expires today"
elif days_left == 1:
return "⚠️ Истекает завтра" if lang == 'ru' else "⚠️ Expires tomorrow"
elif days_left <= 3:
return f"🔶 Осталось {days_left} дней" if lang == 'ru' else f"🔶 {days_left} days left"
return f"https://t.me/{bot_username}?start=ref_{user_id}"
def validate_referral_code(code: str) -> bool:
"""Validate referral code format"""
if not code or not code.startswith("REF"):
return False
if len(code) < 4 or len(code) > 20:
return False
return True
def format_referral_stats(stats: dict, lang: str = 'ru') -> str:
"""Format referral statistics for display"""
if lang == 'ru':
return (f"👥 Приглашено: {stats['total_referrals']}\n"
f"✅ Активных: {stats['active_referrals']}\n"
f"💰 Заработано: {stats['total_earned']:.2f}")
else:
return f"✅ Активна ({days_left} дней)" if lang == 'ru' else f"✅ Active ({days_left} days)"
def format_monitor_notification_type(notification_type: str, lang: str = 'ru') -> str:
"""Format notification type for display"""
type_map = {
'expired': 'Истекла' if lang == 'ru' else 'Expired',
'expires_today': 'Истекает сегодня' if lang == 'ru' else 'Expires today',
'expires_tomorrow': 'Истекает завтра' if lang == 'ru' else 'Expires tomorrow',
'warning': 'Предупреждение' if lang == 'ru' else 'Warning',
'urgent': 'Срочно' if lang == 'ru' else 'Urgent'
}
return type_map.get(notification_type, notification_type)
def calculate_days_until_expiry(expires_at: datetime) -> int:
"""Calculate days until expiry"""
now = datetime.utcnow()
delta = expires_at - now
return max(0, delta.days)
def is_subscription_expiring_soon(expires_at: datetime, warning_days: int = 2) -> bool:
"""Check if subscription is expiring soon"""
days_left = calculate_days_until_expiry(expires_at)
return days_left <= warning_days
class States:
"""State constants for FSM"""
WAITING_LANGUAGE = "waiting_language"
WAITING_AMOUNT = "waiting_amount"
WAITING_PROMOCODE = "waiting_promocode"
# Admin states
ADMIN_CREATE_SUB_NAME = "admin_create_sub_name"
ADMIN_CREATE_SUB_DESC = "admin_create_sub_desc"
ADMIN_CREATE_SUB_PRICE = "admin_create_sub_price"
ADMIN_CREATE_SUB_DAYS = "admin_create_sub_days"
ADMIN_CREATE_SUB_TRAFFIC = "admin_create_sub_traffic"
ADMIN_CREATE_SUB_SQUAD = "admin_create_sub_squad"
ADMIN_ADD_BALANCE_USER = "admin_add_balance_user"
ADMIN_ADD_BALANCE_AMOUNT = "admin_add_balance_amount"
ADMIN_CREATE_PROMO_CODE = "admin_create_promo_code"
ADMIN_CREATE_PROMO_DISCOUNT = "admin_create_promo_discount"
ADMIN_CREATE_PROMO_LIMIT = "admin_create_promo_limit"
return (f"👥 Invited: {stats['total_referrals']}\n"
f"✅ Active: {stats['active_referrals']}\n"
f"💰 Earned: ${stats['total_earned']:.2f}")