Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b14baca51 | |||
| eb1a75c9f5 | |||
| 5e79106134 | |||
| ee45ec356e | |||
| 9275bff828 | |||
| 9150c70618 | |||
| b792e749d6 | |||
| 9871572740 | |||
| 52c1cb3cbc | |||
| d5b2b371c8 | |||
| d5cb022b02 | |||
| 021f62fd30 | |||
| 684e398798 | |||
| 12b64e9978 | |||
| 273df23f20 | |||
| a9b0dbf3eb | |||
| 5f56e068d3 | |||
| 92b82132d8 | |||
| 79ae9d3a1c | |||
| 86714a93f9 | |||
| 553f6f8cf6 | |||
| b561f67c12 | |||
| d03f9b80c7 | |||
| 6d9a5fb578 | |||
| 44d19245c4 | |||
| ce36c268cc | |||
| 1357b07921 | |||
| d4bfdcb749 | |||
| 39621706c8 | |||
| 4684e7a1e5 | |||
| 16bb3c8ad3 | |||
| 16fbdcc655 | |||
| 416d268352 | |||
| 44fb88f368 | |||
| f050c12253 | |||
| 82486d121e | |||
| 2bf382dbac | |||
| ab562e0df0 | |||
| 02e8fba9d0 | |||
| 530df33ba4 | |||
| 8b2e8cb681 | |||
| 2cb8c937e1 |
+7
-2
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+190
-33
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+224
-418
@@ -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
@@ -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:',
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user