Compare commits

...

23 Commits

Author SHA1 Message Date
Fr1ngg 9b14baca51 Update README.md 2025-08-08 05:43:08 +03:00
Fr1ngg eb1a75c9f5 Update .env.example 2025-08-08 05:24:58 +03:00
Fr1ngg 5e79106134 Update admin_handlers.py 2025-08-08 05:22:43 +03:00
Fr1ngg ee45ec356e Update api_error_handlers.py 2025-08-08 05:22:20 +03:00
Fr1ngg 9275bff828 Update config.py 2025-08-08 05:21:51 +03:00
Fr1ngg 9150c70618 Update database.py 2025-08-08 05:21:25 +03:00
Fr1ngg b792e749d6 Update handlers.py 2025-08-08 05:21:00 +03:00
Fr1ngg 9871572740 Update keyboards.py 2025-08-08 05:20:37 +03:00
Fr1ngg 52c1cb3cbc Update main.py 2025-08-08 05:20:16 +03:00
Fr1ngg d5b2b371c8 Update middlewares.py 2025-08-08 05:19:23 +03:00
Fr1ngg d5cb022b02 Update remnawave_api.py 2025-08-08 05:19:06 +03:00
Fr1ngg 021f62fd30 Update subscription_monitor.py 2025-08-08 05:18:41 +03:00
Fr1ngg 684e398798 Update translations.py 2025-08-08 05:18:12 +03:00
Fr1ngg 12b64e9978 Update utils.py 2025-08-08 05:17:45 +03:00
Fr1ngg 273df23f20 Update README.md 2025-08-07 09:32:39 +03:00
Fr1ngg a9b0dbf3eb Update handlers.py 2025-08-07 09:31:28 +03:00
Fr1ngg 5f56e068d3 Update remnawave_api.py 2025-08-07 09:27:59 +03:00
Fr1ngg 92b82132d8 Update remnawave_api.py 2025-08-07 09:27:42 +03:00
Fr1ngg 79ae9d3a1c Update config.py 2025-08-07 09:25:55 +03:00
Fr1ngg 86714a93f9 Update handlers.py 2025-08-07 09:25:40 +03:00
Fr1ngg 553f6f8cf6 Update handlers.py 2025-08-07 09:24:22 +03:00
Fr1ngg b561f67c12 Update utils.py 2025-08-07 09:23:52 +03:00
Fr1ngg d03f9b80c7 Update translations.py 2025-08-07 09:23:26 +03:00
14 changed files with 2458 additions and 1081 deletions
+7 -2
View File
@@ -1,17 +1,22 @@
# Bot Configuration
BOT_TOKEN=
# настройки реф программы юзернейм без @!
BOT_USERNAME=
REFERRAL_FIRST_REWARD=100.0
REFERRAL_REFERRED_BONUS=100.0
REFERRAL_THRESHOLD=200.0
REFERRAL_PERCENTAGE=0.2
# RemnaWave API Configuration
REMNAWAVE_URL=https://example.com
REMNAWAVE_MODE=remote
REMNAWAVE_TOKEN=
SUBSCRIPTION_BASE_URL=https://example.com
# Admin Configuration (comma-separated list of Telegram user IDs)
ADMIN_IDS=123445567,12342123
# Support Configuration
DEFAULT_LANGUAGE=ru
SUPPORT_USERNAME=support
# Database Configuration (optional, defaults to SQLite)
+19 -9
View File
@@ -1,7 +1,5 @@
<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="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" />
#Описание
@@ -13,7 +11,7 @@ RemnaWave Bedolaga Telegram Bot — это многофункциональны
Мультиязычный интерфейс (на данный момент русский и английский языки)
Создание и покупка подписок с управлением трафиком, длительностью и ценой
Создание и покупка подписок
Бесплатная тестовая подписка с заданными ограничениями(срок, лимит трафика, назначение сквада)
@@ -23,6 +21,8 @@ RemnaWave Bedolaga Telegram Bot — это многофункциональны
Промокоды со скидками и ограничениями по использованию
Реф. программа - С несколькими уровнями: 1) награда первое пополнение приглашенному и владельцу реф ссылки. 2) Награда владельцу за каждое последующее пополнение в процентах от суммы пополнения
Полноценная админ-панель с контролем пользователей, созданием подписок(Подтягивает UUID сквада из панели), платежей и статистикой
Рассылка сообщений отдельным пользователям и всем сразу
@@ -65,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
@@ -120,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
@@ -128,8 +137,6 @@ REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
DATABASE_URL — строка подключения к базе данных.
SUBSCRIPTION_BASE_URL=https://sub.example.com (без / на конце)
ADMIN_IDS — через запятую Telegram ID администраторов.
SUPPORT_USERNAME — ник поддержки, без @ указывать
@@ -226,9 +233,12 @@ run.sh — скрипт установки и управления ботом (
Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
1) Дописать службу для оповещения об истечении срока подписки и контроля - Done v1.1.0
2) Подключить различные шлюзы для пополнения баланса
3) Синхранизацию с Remnawave между пользователями по тг id
3) Синхранизацию с Remnawave между пользователями по тг id - Done v1.2.0
4) Полнофункциональную панель упарвления
5) Добавить возможность удаление промокодов - In progress
6) Доработать алгоритм удаления подписок ибо удаление(А НЕ деактивация) сейчас - скроект эту подписку у всех юзеров которые ее купили, так что удаляйте на свой страх и риск я предупредил) - In progress
8) Отправка уведомлений административных в другие чаты-топики
9) Рефка (как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю)
9) Рефка (как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю), ладно трех запросов в личку мне хватило для того. чтобы пересмотреть свое мнение и вкорячить эту шляпу приоритетом - Done v 1.3.0
10) Интегрировать панелья управления Remnawave прямо в бота - Done v1.1.0
А так, други, буду рад баг репортам, по настройке тоже не проблема подсказать, стучите в лс или @fringg
+89 -12
View File
@@ -13,6 +13,7 @@ from keyboards import *
from translations import t
from utils import *
from handlers import BotStates
from referral_utils import process_referral_rewards
try:
from api_error_handlers import (
APIErrorHandler, safe_get_nodes, safe_get_system_users,
@@ -152,7 +153,7 @@ async def create_subscription_callback(callback: CallbackQuery, user: User, stat
async def handle_sub_name(message: Message, state: FSMContext, user: User, **kwargs):
"""Handle subscription name input"""
name = message.text.strip()
if len(name) < 3 or len(name) > 100:
if not (3 <= len(name) <= 100):
await message.answer("❌ Название должно быть от 3 до 100 символов")
return
@@ -920,7 +921,7 @@ async def noop_callback(callback: CallbackQuery, **kwargs):
# Payment approval handlers
@admin_router.callback_query(F.data.startswith("approve_payment_"))
async def approve_payment(callback: CallbackQuery, user: User, db: Database, **kwargs):
"""Approve payment"""
"""Approve payment with referral rewards - ИСПРАВЛЕНА"""
if not await check_admin_access(callback, user):
return
@@ -944,12 +945,14 @@ async def approve_payment(callback: CallbackQuery, user: User, db: Database, **k
payment.status = 'completed'
await db.update_payment(payment)
bot = kwargs.get('bot')
await process_referral_rewards(payment.user_id, payment.amount, payment.id, db, bot)
await callback.message.edit_text(
f"✅ Платеж одобрен!\n💰 Пользователю {payment.user_id} добавлено {payment.amount} руб."
)
# Notify user about successful payment
bot = kwargs.get('bot')
if bot:
try:
await bot.send_message(
@@ -3338,11 +3341,11 @@ async def sync_remnawave_callback(callback: CallbackQuery, user: User, **kwargs)
def sync_remnawave_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
"""Keyboard for RemnaWave sync options"""
buttons = [
[InlineKeyboardButton(text="👥 Синхронизировать пользователей", callback_data="sync_users_remnawave")],
[InlineKeyboardButton(text="📋 Синхронизировать подписки", callback_data="sync_subscriptions_remnawave")],
#[InlineKeyboardButton(text="👥 Синхронизировать пользователей", callback_data="sync_users_remnawave")],
#[InlineKeyboardButton(text="📋 Синхронизировать подписки", callback_data="sync_subscriptions_remnawave")],
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_full_remnawave")],
[InlineKeyboardButton(text="🌍 ИМПОРТ ВСЕХ по Telegram ID", callback_data="import_all_by_telegram")],
[InlineKeyboardButton(text="👤 Синхронизировать одного", callback_data="sync_single_user")],
[InlineKeyboardButton(text="🌍 ИМПОРТ ВСЕХ по Telegram ID", callback_data="import_all_by_telegram")],
[InlineKeyboardButton(text="📋 Просмотр планов", callback_data="view_imported_plans")],
[InlineKeyboardButton(text="📊 Статус синхронизации", callback_data="sync_status_remnawave")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
@@ -3890,24 +3893,24 @@ async def sync_status_remnawave_callback(callback: CallbackQuery, user: User, ap
# Build status text
text = "📊 **Статус синхронизации**\n\n"
text += "**RemnaWave:**\n"
text += "RemnaWave:\n"
text += f"• Всего пользователей: {len(remna_users)}\n"
text += f"С Telegram ID: {remna_with_tg}\n"
text += f"• Без Telegram ID: {remna_without_tg}\n\n"
text += "**Бот:**\n"
text += "Бот:\n"
text += f"• Всего пользователей: {len(bot_users)}\n"
text += f"С RemnaWave UUID: {bot_with_uuid}\n"
text += f"• Без RemnaWave UUID: {bot_without_uuid}\n\n"
text += "**Подписки:**\n"
text += "Подписки:\n"
text += f"• Всего в боте: {total_bot_subs}\n"
text += f"• Синхронизировано: {synced_subs}\n"
text += f"• Не синхронизировано: {total_bot_subs - synced_subs}\n\n"
# Recommendations
if bot_without_uuid > 0 or remna_without_tg > 0 or (total_bot_subs - synced_subs) > 0:
text += "⚠️ **Рекомендации:**\n"
text += "⚠️ Рекомендации:\n"
if bot_without_uuid > 0:
text += f"{bot_without_uuid} пользователей бота не связаны с RemnaWave\n"
if remna_without_tg > 0:
@@ -3916,7 +3919,7 @@ async def sync_status_remnawave_callback(callback: CallbackQuery, user: User, ap
text += f"{total_bot_subs - synced_subs} подписок не синхронизированы\n"
text += "\n💡 Рекомендуется выполнить полную синхронизацию\n"
else:
text += "**Все данные синхронизированы**\n"
text += "✅ Все данные синхронизированы\n"
text += f"\n🕐 _Проверено: {format_datetime(datetime.now(), user.language)}_"
@@ -3935,7 +3938,6 @@ async def sync_status_remnawave_callback(callback: CallbackQuery, user: User, ap
# User filtering handlers
@admin_router.callback_query(F.data == "filter_users_active")
async def filter_users_active_callback(callback: CallbackQuery, user: User, api: RemnaWaveAPI = None, **kwargs):
"""Show only active users - ИСПРАВЛЕНО"""
if not await check_admin_access(callback, user):
return
@@ -5638,3 +5640,78 @@ async def debug_all_plans_callback(callback: CallbackQuery, user: User, db: Data
except Exception as e:
logger.error(f"Error debugging all plans: {e}")
await callback.answer("❌ Ошибка анализа планов", show_alert=True)
@admin_router.callback_query(F.data == "admin_referrals")
async def admin_referrals_callback(callback: CallbackQuery, user: User, **kwargs):
"""Show referral management"""
if not await check_admin_access(callback, user):
return
await callback.message.edit_text(
"👥 Управление реферальной программой",
reply_markup=admin_referrals_keyboard(user.language)
)
@admin_router.callback_query(F.data == "referral_statistics")
async def referral_statistics_callback(callback: CallbackQuery, user: User, db: Database, **kwargs):
"""Show referral statistics"""
if not await check_admin_access(callback, user):
return
try:
# Получаем общую статистику
async with db.session_factory() as session:
from sqlalchemy import select, func
# Общее количество рефералов
total_referrals = await session.execute(
select(func.count(ReferralProgram.id))
)
total_referrals = total_referrals.scalar() or 0
# Активные рефералы (получившие первую награду)
active_referrals = await session.execute(
select(func.count(ReferralProgram.id))
.where(ReferralProgram.first_reward_paid == True)
)
active_referrals = active_referrals.scalar() or 0
# Общая сумма выплат
total_paid = await session.execute(
select(func.sum(ReferralEarning.amount))
)
total_paid = total_paid.scalar() or 0.0
# Топ рефереров
top_referrers = await session.execute(
select(ReferralProgram.referrer_id, func.count(ReferralProgram.id).label('count'))
.group_by(ReferralProgram.referrer_id)
.order_by(func.count(ReferralProgram.id).desc())
.limit(5)
)
top_referrers = list(top_referrers.fetchall())
text = "📊 Статистика реферальной программы\n\n"
text += f"👥 Всего рефералов: {total_referrals}\n"
text += f"✅ Активных рефералов: {active_referrals}\n"
text += f"💰 Выплачено всего: {total_paid:.2f}\n"
text += f"📈 Конверсия: {(active_referrals/total_referrals*100):.1f}%" if total_referrals > 0 else "📈 Конверсия: 0%"
if top_referrers:
text += f"\n\n🏆 Топ рефереров:\n"
for i, (referrer_id, count) in enumerate(top_referrers, 1):
referrer = await db.get_user_by_telegram_id(referrer_id)
username = referrer.username if referrer and referrer.username else "Unknown"
text += f"{i}. @{username}: {count} рефералов\n"
await callback.message.edit_text(
text,
reply_markup=back_keyboard("admin_referrals", user.language)
)
except Exception as e:
logger.error(f"Error getting referral statistics: {e}")
await callback.message.edit_text(
"❌ Ошибка получения статистики",
reply_markup=back_keyboard("admin_referrals", user.language)
)
-2
View File
@@ -1,5 +1,3 @@
# api_error_handlers.py - Дополнительные утилиты для обработки ошибок API
import logging
from typing import Optional, Dict, Any, Callable
from aiogram.types import CallbackQuery
+154 -55
View File
@@ -1,73 +1,172 @@
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List
import logging
try:
from dotenv import load_dotenv
load_dotenv()
print("✅ .env файл загружен успешно")
except ImportError:
print("⚠️ python-dotenv не установлен. Используются системные переменные окружения.")
except Exception as e:
print(f"⚠️ Ошибка загрузки .env файла: {e}")
logger = logging.getLogger(__name__)
@dataclass
class Config:
BOT_TOKEN: str
REMNAWAVE_URL: str
REMNAWAVE_TOKEN: str
REMNAWAVE_MODE: str
DATABASE_URL: str
ADMIN_IDS: List[int]
DEFAULT_LANGUAGE: str
SUPPORT_USERNAME: str
# Bot settings
BOT_TOKEN: str = field(default="")
DATABASE_URL: str = field(default="sqlite+aiosqlite:///bot.db")
# Subscription URL settings
SUBSCRIPTION_BASE_URL: str
# RemnaWave API settings
REMNAWAVE_URL: str = field(default="")
REMNAWAVE_TOKEN: str = field(default="")
SUBSCRIPTION_BASE_URL: str = field(default="")
# Admin settings
ADMIN_IDS: List[int] = field(default_factory=list)
SUPPORT_USERNAME: str = field(default="support")
# Bot settings
DEFAULT_LANGUAGE: str = field(default="ru")
BOT_USERNAME: str = field(default="") # ИМЯ БОТА ДЛЯ РЕФЕРАЛЬНЫХ ССЫЛОК
# Trial subscription settings
TRIAL_ENABLED: bool
TRIAL_DURATION_DAYS: int
TRIAL_TRAFFIC_GB: int
TRIAL_SQUAD_UUID: str
TRIAL_PRICE: float
TRIAL_ENABLED: bool = field(default=False)
TRIAL_DURATION_DAYS: int = field(default=3)
TRIAL_TRAFFIC_GB: int = field(default=2)
TRIAL_SQUAD_UUID: str = field(default="")
# Monitor service settings
MONITOR_CHECK_INTERVAL: int
MONITOR_DAILY_CHECK_HOUR: int
MONITOR_WARNING_DAYS: int
MONITOR_WARNING_DAYS: int = field(default=3)
MONITOR_CHECK_INTERVAL: int = field(default=3600) # 1 час
MONITOR_DAILY_CHECK_HOUR: int = field(default=9) # 9 утра
# Referral program settings
REFERRAL_FIRST_REWARD: float = field(default=150.0) # Первая награда
REFERRAL_REFERRED_BONUS: float = field(default=150.0) # Бонус приглашенному
REFERRAL_THRESHOLD: float = field(default=300.0) # Порог для получения бонуса
REFERRAL_PERCENTAGE: float = field(default=0.25) # 25% с платежей
def parse_admin_ids(admin_ids_str: str) -> List[int]:
"""Parse admin IDs from string"""
if not admin_ids_str:
return []
try:
# Support both comma and space separated
ids_str = admin_ids_str.replace(',', ' ').strip()
return [int(id_str.strip()) for id_str in ids_str.split() if id_str.strip().isdigit()]
except ValueError as e:
logger.error(f"Error parsing admin IDs: {e}")
return []
def str_to_bool(value: str) -> bool:
"""Convert string to boolean"""
if isinstance(value, bool):
return value
return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
def load_config() -> Config:
"""Load configuration from environment variables"""
# Parse admin IDs
admin_ids_str = os.getenv('ADMIN_IDS', '')
admin_ids = []
if admin_ids_str:
try:
admin_ids = [int(x.strip()) for x in admin_ids_str.split(',') if x.strip()]
except ValueError:
admin_ids = []
# Get subscription base URL -
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
# Дебаг: проверяем что переменные загружены
print(f"🔍 BOT_USERNAME из env: '{os.getenv('BOT_USERNAME', 'НЕ НАЙДЕН')}'")
print(f"🔍 REFERRAL_FIRST_REWARD из env: '{os.getenv('REFERRAL_FIRST_REWARD', 'НЕ НАЙДЕН')}'")
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
if not subscription_base_url:
subscription_base_url = 'https://sub.fring.tech'
return Config(
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
REMNAWAVE_MODE=os.getenv('REMNAWAVE_MODE', 'local'),
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
ADMIN_IDS=admin_ids,
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
config = Config(
# Bot settings
BOT_TOKEN=os.getenv("BOT_TOKEN", ""),
DATABASE_URL=os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db"),
# Subscription URL
SUBSCRIPTION_BASE_URL=subscription_base_url,
# RemnaWave API settings
REMNAWAVE_URL=os.getenv("REMNAWAVE_URL", ""),
REMNAWAVE_TOKEN=os.getenv("REMNAWAVE_TOKEN", ""),
SUBSCRIPTION_BASE_URL=os.getenv("SUBSCRIPTION_BASE_URL", ""),
# Trial subscription settings
TRIAL_ENABLED=os.getenv('TRIAL_ENABLED', 'true').lower() == 'true',
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
# Admin settings
ADMIN_IDS=parse_admin_ids(os.getenv("ADMIN_IDS", "")),
SUPPORT_USERNAME=os.getenv("SUPPORT_USERNAME", "support"),
# Monitor service settings
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
# Bot settings
DEFAULT_LANGUAGE=os.getenv("DEFAULT_LANGUAGE", "ru"),
BOT_USERNAME=os.getenv("BOT_USERNAME", ""), # ИСПРАВЛЕНО: берется из env
# Trial settings
TRIAL_ENABLED=str_to_bool(os.getenv("TRIAL_ENABLED", "false")),
TRIAL_DURATION_DAYS=int(os.getenv("TRIAL_DURATION_DAYS", "3")),
TRIAL_TRAFFIC_GB=int(os.getenv("TRIAL_TRAFFIC_GB", "2")),
TRIAL_SQUAD_UUID=os.getenv("TRIAL_SQUAD_UUID", ""),
MONITOR_WARNING_DAYS=int(os.getenv("MONITOR_WARNING_DAYS", "3")),
MONITOR_CHECK_INTERVAL=int(os.getenv("MONITOR_CHECK_INTERVAL", "3600")),
MONITOR_DAILY_CHECK_HOUR=int(os.getenv("MONITOR_DAILY_CHECK_HOUR", "9")),
# Referral settings
REFERRAL_FIRST_REWARD=float(os.getenv("REFERRAL_FIRST_REWARD", "150.0")),
REFERRAL_REFERRED_BONUS=float(os.getenv("REFERRAL_REFERRED_BONUS", "150.0")),
REFERRAL_THRESHOLD=float(os.getenv("REFERRAL_THRESHOLD", "300.0")),
REFERRAL_PERCENTAGE=float(os.getenv("REFERRAL_PERCENTAGE", "0.25")),
)
print(f"✅ Config BOT_USERNAME: '{config.BOT_USERNAME}'")
print(f"✅ Config REFERRAL_FIRST_REWARD: {config.REFERRAL_FIRST_REWARD}")
return config
def load_config_manual_dotenv() -> Config:
"""Load config with manual .env parsing"""
# Загружаем .env файл вручную если python-dotenv не доступен
env_path = ".env"
if os.path.exists(env_path):
try:
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
# Убираем кавычки если есть
value = value.strip('"\'')
os.environ[key] = value
print(f"✅ Файл {env_path} загружен вручную")
except Exception as e:
print(f"⚠️ Ошибка загрузки {env_path}: {e}")
return load_config()
def debug_environment():
"""Debug environment variables loading"""
print("\n=== DEBUG ENVIRONMENT VARIABLES ===")
# Ключевые переменные для проверки
key_vars = [
'BOT_TOKEN', 'BOT_USERNAME', 'REMNAWAVE_URL', 'REMNAWAVE_TOKEN',
'ADMIN_IDS', 'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD'
]
for var in key_vars:
value = os.getenv(var, 'НЕ УСТАНОВЛЕНА')
print(f"{var}: {value}")
print("=" * 40)
# Проверяем файл .env
if os.path.exists('.env'):
print("📁 Файл .env найден")
try:
with open('.env', 'r') as f:
lines = f.readlines()
print(f"📄 Строк в .env: {len(lines)}")
for i, line in enumerate(lines[:5], 1): # Первые 5 строк
if 'TOKEN' not in line: # Не показываем токены
print(f" {i}: {line.strip()}")
except Exception as e:
print(f"❌ Ошибка чтения .env: {e}")
else:
print("❌ Файл .env не найден в текущей директории")
print(f"📍 Текущая директория: {os.getcwd()}")
print("=" * 40 + "\n")
+404 -1
View File
@@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sess
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
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'
@@ -106,6 +129,49 @@ class Database:
# Выполняем миграции
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()
@@ -733,3 +799,340 @@ class Database:
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
+684 -203
View File
File diff suppressed because it is too large Load Diff
+34 -11
View File
@@ -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")])
@@ -180,7 +189,10 @@ def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
# Третий ряд - коммуникации и система
[
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system") # НОВОЕ!
InlineKeyboardButton(text="👥 Рефералы", callback_data="admin_referrals") # НОВАЯ КНОПКА
],
[
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system")
],
# Четвертый ряд - мониторинг и статистика
[
@@ -505,3 +517,14 @@ def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKey
]
])
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
+44 -2
View File
@@ -7,8 +7,20 @@ from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
# Import our modules
from config import load_config
# ДОБАВЛЯЕМ ДЕБАГ ЗАГРУЗКИ .env ПЕРЕД ИМПОРТОМ CONFIG
print("🚀 Запуск бота...")
print(f"📍 Рабочая директория: {os.getcwd()}")
print(f"📁 Файлы в директории: {os.listdir('.')}")
# Проверяем наличие .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
@@ -40,9 +52,19 @@ class BotApplication:
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")
@@ -52,9 +74,15 @@ class BotApplication:
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)
@@ -117,6 +145,13 @@ class BotApplication:
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
@@ -180,6 +215,13 @@ class BotApplication:
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:
+5 -5
View File
@@ -23,7 +23,7 @@ class DatabaseMiddleware(BaseMiddleware):
return await handler(event, data)
class UserMiddleware(BaseMiddleware):
"""Middleware for user management"""
"""Middleware for user management - ИСПРАВЛЕНО: правильная инициализация языка"""
def __init__(self, db: Database, config: Config):
self.db = db
@@ -46,16 +46,16 @@ class UserMiddleware(BaseMiddleware):
# Check if user is admin
is_admin = telegram_user.id in self.config.ADMIN_IDS
# Create new user
# ИСПРАВЛЕНО: Создаем пользователя с пустым языком для первоначальной настройки
user = await self.db.create_user(
telegram_id=telegram_user.id,
username=telegram_user.username,
first_name=telegram_user.first_name,
last_name=telegram_user.last_name,
language=self.config.DEFAULT_LANGUAGE,
language='', # ИЗМЕНЕНО: пустой язык для нового пользователя
is_admin=is_admin
)
logger.info(f"Created new user: {telegram_user.id}")
logger.info(f"Created new user: {telegram_user.id} without language")
else:
# Update user info if changed
updated = False
@@ -79,7 +79,7 @@ class UserMiddleware(BaseMiddleware):
await self.db.update_user(user)
data['user'] = user
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
data['lang'] = user.language if user.language and user.language != '' else self.config.DEFAULT_LANGUAGE
except Exception as e:
logger.error(f"Error in UserMiddleware: {e}")
+259 -122
View File
@@ -11,7 +11,8 @@ class RemnaWaveAPI:
def __init__(self, base_url: str, token: str, subscription_base_url: str = None):
self.base_url = base_url.rstrip('/')
self.token = token
self.subscription_base_url = subscription_base_url or base_url
# УДАЛЯЕМ зависимость от subscription_base_url - теперь берем из API
self.subscription_base_url = subscription_base_url # Оставляем для обратной совместимости
self.session = None
async def _get_session(self):
@@ -19,7 +20,7 @@ class RemnaWaveAPI:
headers = {
'Authorization': f'Bearer {self.token}',
'Content-Type': 'application/json',
'Accept': 'application/json' # Явно запрашиваем JSON
'Accept': 'application/json'
}
timeout = aiohttp.ClientTimeout(total=30)
self.session = aiohttp.ClientSession(
@@ -77,7 +78,138 @@ class RemnaWaveAPI:
logger.error(f"Request error for {endpoint}: {e}")
return None
# User management
async def get_subscription_info(self, short_uuid: str) -> Optional[Dict]:
"""Get subscription info including URL from RemnaWave API"""
try:
logger.info(f"Getting subscription info for short_uuid: {short_uuid}")
# Пробуем разные эндпоинты для получения информации о подписке
endpoints_to_try = [
f'/api/subscriptions/{short_uuid}',
f'/api/sub/{short_uuid}',
f'/api/subscription/{short_uuid}'
]
for endpoint in endpoints_to_try:
logger.debug(f"Trying endpoint: {endpoint}")
result = await self._make_request('GET', endpoint)
if result:
logger.info(f"Successfully got subscription info from {endpoint}")
# Обрабатываем разные структуры ответа
subscription_data = None
if 'response' in result:
subscription_data = result['response']
elif 'data' in result:
subscription_data = result['data']
elif 'subscription' in result:
subscription_data = result['subscription']
else:
subscription_data = result
# Проверяем что получили нужные данные
if subscription_data and (
'subscriptionUrl' in subscription_data or
'url' in subscription_data or
'link' in subscription_data
):
return subscription_data
logger.warning(f"Could not get subscription info for {short_uuid} from any endpoint")
return None
except Exception as e:
logger.error(f"Error getting subscription info for {short_uuid}: {e}")
return None
async def get_subscription_url(self, short_uuid: str) -> str:
"""Get subscription URL from RemnaWave API - ОБНОВЛЕННЫЙ МЕТОД"""
try:
logger.info(f"Getting subscription URL for short_uuid: {short_uuid}")
# Сначала пробуем получить URL из API
subscription_info = await self.get_subscription_info(short_uuid)
if subscription_info:
# Ищем URL в разных возможных полях
subscription_url = (
subscription_info.get('subscriptionUrl') or
subscription_info.get('url') or
subscription_info.get('link') or
subscription_info.get('subscription_url')
)
if subscription_url:
logger.info(f"Got subscription URL from API: {subscription_url}")
return subscription_url
# Если не получилось через API, пробуем через пользователя
user_data = await self.get_user_by_short_uuid(short_uuid)
if user_data and 'subscriptionUrl' in user_data:
logger.info(f"Got subscription URL from user data: {user_data['subscriptionUrl']}")
return user_data['subscriptionUrl']
# Последний fallback - формируем URL самостоятельно
if self.subscription_base_url:
fallback_url = f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
logger.warning(f"Using fallback URL: {fallback_url}")
return fallback_url
else:
# Если нет даже fallback URL, пытаемся угадать из base_url
fallback_url = f"{self.base_url.rstrip('/')}/sub/{short_uuid}"
logger.warning(f"Using base_url fallback: {fallback_url}")
return fallback_url
except Exception as e:
logger.error(f"Failed to get subscription URL for {short_uuid}: {e}")
# Последний resort
fallback_url = f"{self.base_url.rstrip('/')}/sub/{short_uuid}"
return fallback_url
async def get_all_subscriptions_with_urls(self) -> Optional[List]:
"""Get all subscriptions with their URLs from RemnaWave API"""
try:
logger.info("Fetching all subscriptions with URLs from API")
result = await self._make_request('GET', '/api/subscriptions')
if not result:
logger.error("Empty response from subscriptions API")
return []
subscriptions_list = []
# Extract subscriptions from response
if 'response' in result and 'subscriptions' in result['response']:
subscriptions_list = result['response']['subscriptions']
elif 'subscriptions' in result:
subscriptions_list = result['subscriptions']
elif 'data' in result:
subscriptions_list = result['data']
elif isinstance(result, list):
subscriptions_list = result
# Process subscriptions to ensure they have URLs
processed_subscriptions = []
for subscription in subscriptions_list:
if subscription.get('isFound') and 'user' in subscription:
user_data = subscription['user']
# Добавляем URL если его нет
if 'subscriptionUrl' not in subscription and user_data.get('shortUuid'):
subscription['subscriptionUrl'] = await self.get_subscription_url(user_data['shortUuid'])
processed_subscriptions.append(subscription)
logger.info(f"Processed {len(processed_subscriptions)} subscriptions with URLs")
return processed_subscriptions
except Exception as e:
logger.error(f"Exception in get_all_subscriptions_with_urls: {e}", exc_info=True)
return []
# User management - без изменений
async def create_user(self, username: str, password: str = None,
traffic_limit: int = 0, expiry_time: str = None,
telegram_id: int = None, email: str = None,
@@ -143,7 +275,6 @@ class RemnaWaveAPI:
elif 'user' in result:
user_data = result['user']
else:
# Если нет стандартных полей, возможно сам result - это пользователь
if 'telegramId' in result or 'username' in result:
user_data = result
elif isinstance(result, list):
@@ -155,6 +286,16 @@ class RemnaWaveAPI:
# Проверяем что это действительно нужный пользователь
if user_data.get('telegramId') == telegram_id:
logger.info(f"Found user: {user_data.get('username')} for Telegram ID {telegram_id}")
# НОВОЕ: Обогащаем данные пользователя URL подписки если есть short_uuid
if user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
logger.debug(f"Added subscription URL to user data: {subscription_url}")
except Exception as e:
logger.warning(f"Could not get subscription URL for user: {e}")
return user_data
else:
logger.warning(f"Telegram ID mismatch: expected {telegram_id}, got {user_data.get('telegramId')}")
@@ -167,11 +308,23 @@ class RemnaWaveAPI:
result = await self._make_request('GET', f'/api/users/{uuid}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL for user: {e}")
return user_data
return None
async def get_user_by_short_uuid(self, short_uuid: str) -> Optional[Dict]:
@@ -180,11 +333,23 @@ class RemnaWaveAPI:
result = await self._make_request('GET', f'/api/users/by-short-uuid/{short_uuid}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(short_uuid)
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def update_user(self, uuid: str, data: Dict) -> Optional[Dict]:
@@ -246,18 +411,8 @@ class RemnaWaveAPI:
'trafficLimitBytes': traffic_bytes
}
return await self.update_user(uuid, update_data)
async def get_subscription_url(self, short_uuid: str) -> str:
"""Get subscription URL for a given short UUID"""
try:
subscription_url = f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
logger.info(f"Generated subscription URL: {subscription_url}")
return subscription_url
except Exception as e:
logger.error(f"Failed to get subscription URL: {e}")
return f"{self.subscription_base_url.rstrip('/')}/sub/{short_uuid}"
# Nodes management - FIXED with proper endpoints
# Nodes management
async def get_all_nodes(self) -> Optional[List]:
"""Get all nodes with proper field mapping"""
try:
@@ -381,7 +536,7 @@ class RemnaWaveAPI:
"""Disable specific node"""
return await self._make_request('POST', f'/api/nodes/{node_id}/actions/disable')
# System stats
# System stats - без изменений
async def get_system_stats(self) -> Optional[Dict]:
"""Get system statistics with proper parsing"""
try:
@@ -432,13 +587,13 @@ class RemnaWaveAPI:
return None
async def get_all_system_users_full(self) -> Optional[List]:
"""Get all system users without pagination - FIXED"""
"""Get all system users with subscription URLs - ОБНОВЛЕННЫЙ МЕТОД"""
try:
all_users = []
offset = 0
limit = 100
logger.info("Starting to fetch all system users")
logger.info("Starting to fetch all system users with URLs")
while True:
logger.debug(f"Fetching users batch: offset={offset}, limit={limit}")
@@ -484,12 +639,23 @@ class RemnaWaveAPI:
if not batch_users:
logger.info(f"No users in batch at offset {offset}, stopping")
break
# Логируем первого пользователя для отладки структуры
if batch_users and offset == 0:
logger.debug(f"First user structure: {list(batch_users[0].keys()) if batch_users[0] else 'empty'}")
all_users.extend(batch_users)
# НОВОЕ: Обогащаем каждого пользователя subscription URL
enriched_users = []
for user in batch_users:
try:
# Добавляем subscription URL если есть shortUuid и его еще нет
if user.get('shortUuid') and 'subscriptionUrl' not in user:
subscription_url = await self.get_subscription_url(user['shortUuid'])
user['subscriptionUrl'] = subscription_url
logger.debug(f"Added subscription URL for user {user.get('username', 'unknown')}")
enriched_users.append(user)
except Exception as e:
logger.warning(f"Could not enrich user {user.get('username', 'unknown')} with URL: {e}")
enriched_users.append(user) # Добавляем пользователя даже без URL
all_users.extend(enriched_users)
# Проверяем условия остановки
if len(batch_users) < limit:
@@ -511,7 +677,8 @@ class RemnaWaveAPI:
# Финальная статистика
if all_users:
active_users = len([u for u in all_users if str(u.get('status', '')).upper() == 'ACTIVE'])
logger.info(f"Successfully fetched {len(all_users)} users (Active: {active_users})")
users_with_urls = len([u for u in all_users if u.get('subscriptionUrl')])
logger.info(f"Successfully fetched {len(all_users)} users (Active: {active_users}, With URLs: {users_with_urls})")
else:
logger.warning("No users found in system")
@@ -521,6 +688,9 @@ class RemnaWaveAPI:
logger.error(f"Error getting all system users: {e}", exc_info=True)
return []
# Остальные методы остаются без изменений...
# (Internal squads, user search, user actions, bulk operations, debug methods, health check, etc.)
# Internal squads
async def get_internal_squads_list(self) -> Optional[List[Dict]]:
"""Get list of internal squads"""
@@ -561,7 +731,6 @@ class RemnaWaveAPI:
return 0
async def debug_users_api(self) -> Dict:
"""Debug method to check users API response structure"""
try:
logger.info("=== DEBUGGING USERS API ===")
@@ -631,33 +800,69 @@ class RemnaWaveAPI:
"""Get user by username"""
result = await self._make_request('GET', f'/api/users/by-username/{username}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def get_user_by_email(self, email: str) -> Optional[Dict]:
"""Get user by email"""
result = await self._make_request('GET', f'/api/users/by-email/{email}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
async def get_user_by_tag(self, tag: str) -> Optional[Dict]:
"""Get user by tag"""
result = await self._make_request('GET', f'/api/users/by-tag/{tag}')
if result:
user_data = None
if 'response' in result:
return result['response']
user_data = result['response']
elif 'data' in result:
return result['data']
return result
user_data = result['data']
else:
user_data = result
# Добавляем subscription URL если его нет
if user_data and user_data.get('shortUuid') and 'subscriptionUrl' not in user_data:
try:
subscription_url = await self.get_subscription_url(user_data['shortUuid'])
user_data['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL: {e}")
return user_data
return None
# User actions
@@ -821,8 +1026,9 @@ class RemnaWaveAPI:
logger.error(f"Error getting nodes statistics: {e}")
return {'data': []}
# ОБНОВЛЯЕМ метод get_all_subscriptions чтобы правильно обрабатывать URLs
async def get_all_subscriptions(self) -> Optional[List]:
"""Get all subscriptions from RemnaWave"""
"""Get all subscriptions from RemnaWave - ОБНОВЛЕННАЯ ВЕРСИЯ"""
try:
logger.info("Fetching all subscriptions from API")
result = await self._make_request('GET', '/api/subscriptions')
@@ -859,9 +1065,19 @@ class RemnaWaveAPI:
'trafficUsed': user_data.get('trafficUsed', '0'),
'trafficLimit': user_data.get('trafficLimit', '0'),
'daysLeft': user_data.get('daysLeft', 0),
'subscriptionUrl': subscription.get('subscriptionUrl'),
# ВАЖНО: Используем subscriptionUrl из самой подписки, а не генерируем
'subscriptionUrl': subscription.get('subscriptionUrl') or subscription.get('url'),
'links': subscription.get('links', [])
}
# Если нет URL в подписке, пытаемся получить его
if not processed_user.get('subscriptionUrl') and processed_user.get('shortUuid'):
try:
subscription_url = await self.get_subscription_url(processed_user['shortUuid'])
processed_user['subscriptionUrl'] = subscription_url
except Exception as e:
logger.warning(f"Could not get subscription URL for {processed_user.get('username')}: {e}")
processed_users.append(processed_user)
logger.info(f"Processed {len(processed_users)} active subscriptions")
@@ -871,85 +1087,6 @@ class RemnaWaveAPI:
logger.error(f"Exception in get_all_subscriptions: {e}", exc_info=True)
return []
async def get_all_system_users_full(self) -> Optional[List]:
"""Get all system users - updated to use subscriptions API"""
try:
# Сначала пробуем получить через API пользователей
logger.info("Trying to get users via /api/users")
users_result = await self._get_users_paginated()
if users_result:
logger.info(f"Got {len(users_result)} users from users API")
return users_result
# Если не получилось, пробуем через подписки
logger.info("Fallback to subscriptions API")
subscriptions_users = await self.get_all_subscriptions()
if subscriptions_users:
logger.info(f"Got {len(subscriptions_users)} users from subscriptions API")
return subscriptions_users
logger.warning("No users found via any API endpoint")
return []
except Exception as e:
logger.error(f"Error getting all system users: {e}", exc_info=True)
return []
async def _get_users_paginated(self) -> Optional[List]:
"""Get users via paginated API calls"""
try:
all_users = []
offset = 0
limit = 100
while True:
logger.debug(f"Fetching users batch: offset={offset}, limit={limit}")
result = await self._make_request('GET', '/api/users',
params={'offset': offset, 'limit': limit})
if not result:
break
batch_users = []
# Extract users from various response structures
if isinstance(result, dict):
if 'users' in result:
batch_users = result['users'] if isinstance(result['users'], list) else []
elif 'data' in result:
batch_users = result['data'] if isinstance(result['data'], list) else []
elif 'response' in result:
if isinstance(result['response'], dict):
if 'users' in result['response']:
batch_users = result['response']['users'] if isinstance(result['response']['users'], list) else []
elif 'data' in result['response']:
batch_users = result['response']['data'] if isinstance(result['response']['data'], list) else []
elif isinstance(result['response'], list):
batch_users = result['response']
elif isinstance(result, list):
batch_users = result
if not batch_users:
break
all_users.extend(batch_users)
if len(batch_users) < limit:
break
offset += limit
if offset > 10000: # Safety limit
break
return all_users if all_users else None
except Exception as e:
logger.error(f"Error in _get_users_paginated: {e}")
return None
async def bulk_reset_all_traffic(self) -> Optional[Dict]:
"""Reset traffic for all users using the correct API endpoint"""
try:
+224 -418
View File
@@ -1,503 +1,309 @@
"""
Subscription Monitor Service
Сервис для мониторинга подписок, уведомлений пользователей и предложений продления
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from dataclasses import dataclass
import traceback
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from database import Database, UserSubscription, Subscription, User
from typing import Optional, List
from database import Database
from remnawave_api import RemnaWaveAPI
from translations import t
from keyboards import extend_subscription_keyboard, main_menu_keyboard
from utils import format_datetime, log_user_action
from config import Config
# Настройка логирования
logger = logging.getLogger(__name__)
@dataclass
class NotificationResult:
"""Результат отправки уведомления"""
success: bool
user_id: int
message: str
error: Optional[str] = None
class SubscriptionMonitorService:
"""Сервис мониторинга подписок"""
"""Service for monitoring subscriptions and sending notifications"""
def __init__(self, bot: Bot, db: Database, config: Config, api: Optional[RemnaWaveAPI] = None):
def __init__(self, bot, db: Database, config, api: RemnaWaveAPI = None):
self.bot = bot
self.db = db
self.config = config
self.api = api
self.is_running = False
self._monitor_task: Optional[asyncio.Task] = None
# Настройки уведомлений из конфига
self.WARNING_DAYS = config.MONITOR_WARNING_DAYS # За сколько дней предупреждать
self.CHECK_INTERVAL = config.MONITOR_CHECK_INTERVAL # Интервал проверки (в секундах)
self.DAILY_CHECK_HOUR = config.MONITOR_DAILY_CHECK_HOUR # В какой час дня делать основную
self._monitor_task = None
self._daily_task = None
async def start(self):
"""Запуск сервиса мониторинга"""
"""Start the monitoring service"""
if self.is_running:
logger.warning("Subscription monitor service is already running")
logger.warning("Monitor service is already running")
return
self.is_running = True
# Start periodic monitoring task
self._monitor_task = asyncio.create_task(self._monitor_loop())
# Start daily cleanup task
self._daily_task = asyncio.create_task(self._daily_loop())
logger.info("Subscription monitor service started")
async def stop(self):
"""Остановка сервиса мониторинга"""
"""Stop the monitoring service"""
if not self.is_running:
return
self.is_running = False
# Cancel tasks
if self._monitor_task:
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
if self._daily_task:
self._daily_task.cancel()
try:
await self._daily_task
except asyncio.CancelledError:
pass
logger.info("Subscription monitor service stopped")
async def _monitor_loop(self):
"""Основной цикл мониторинга"""
logger.info(f"Starting monitor loop with {self.CHECK_INTERVAL}s interval")
"""Main monitoring loop"""
while self.is_running:
try:
current_time = datetime.utcnow()
# Основная проверка раз в день в определенное время
if current_time.hour == self.DAILY_CHECK_HOUR:
await self._daily_check()
# Дополнительная проверка каждый час для критических случаев
await self._hourly_check()
# Ожидание до следующей проверки
await asyncio.sleep(self.CHECK_INTERVAL)
await self._check_expiring_subscriptions()
await asyncio.sleep(self.config.MONITOR_CHECK_INTERVAL)
except asyncio.CancelledError:
logger.info("Monitor loop cancelled")
break
except Exception as e:
logger.error(f"Error in monitor loop: {e}")
logger.error(traceback.format_exc())
# Продолжаем работу даже при ошибках
await asyncio.sleep(60) # Короткая пауза при ошибке
await asyncio.sleep(60) # Wait 1 minute before retry
async def _daily_check(self):
"""Ежедневная проверка всех подписок"""
logger.info("Starting daily subscription check")
async def _daily_loop(self):
"""Daily cleanup loop"""
while self.is_running:
try:
now = datetime.now()
# Wait until the specified hour
if now.hour == self.config.MONITOR_DAILY_CHECK_HOUR:
await self.force_daily_check()
# Wait until next day
tomorrow = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0) + timedelta(days=1)
sleep_time = (tomorrow - now).total_seconds()
else:
# Calculate time until next check
target_time = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0)
if target_time < now:
target_time += timedelta(days=1)
sleep_time = (target_time - now).total_seconds()
await asyncio.sleep(min(sleep_time, 3600)) # Check at least every hour
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in daily loop: {e}")
await asyncio.sleep(3600) # Wait 1 hour before retry
async def _check_expiring_subscriptions(self):
"""Check for expiring subscriptions and send warnings"""
try:
# Получаем все активные подписки пользователей
# Get all users
all_users = await self.db.get_all_users()
total_notifications = 0
for user in all_users:
try:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
active_subs = [sub for sub in user_subs if sub.is_active]
# Get expiring subscriptions for this user
expiring_subs = await self.db.get_expiring_subscriptions(
user.telegram_id,
self.config.MONITOR_WARNING_DAYS
)
for user_sub in active_subs:
# Проверяем каждую подписку
notification_sent = await self._check_and_notify_subscription(user, user_sub)
if notification_sent:
total_notifications += 1
# Небольшая пауза между уведомлениями
await asyncio.sleep(0.1)
for user_sub in expiring_subs:
await self._send_expiry_warning(user, user_sub)
except Exception as e:
logger.error(f"Error checking subscriptions for user {user.telegram_id}: {e}")
continue
logger.info(f"Daily check completed. Sent {total_notifications} notifications")
except Exception as e:
logger.error(f"Error in daily check: {e}")
logger.error(traceback.format_exc())
async def _hourly_check(self):
"""Часовая проверка критических подписок (истекают сегодня)"""
try:
now = datetime.utcnow()
tomorrow = now + timedelta(days=1)
# Получаем подписки, которые истекают в ближайшие 24 часа
all_users = await self.db.get_all_users()
for user in all_users:
try:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
if (user_sub.is_active and
user_sub.expires_at <= tomorrow and
user_sub.expires_at > now):
await self._check_and_notify_subscription(user, user_sub, urgent=True)
await asyncio.sleep(0.1)
except Exception as e:
logger.error(f"Error in hourly check for user {user.telegram_id}: {e}")
continue
except Exception as e:
logger.error(f"Error in hourly check: {e}")
logger.error(f"Error in check_expiring_subscriptions: {e}")
async def _check_and_notify_subscription(self, user: User, user_sub: UserSubscription, urgent: bool = False) -> bool:
"""
Проверить подписку и отправить уведомление если нужно
Returns: True если уведомление было отправлено
"""
async def _send_expiry_warning(self, user, user_subscription):
"""Send expiry warning to user"""
try:
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
days_left = (user_subscription.expires_at - datetime.utcnow()).days
# Получаем информацию о подписке
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
subscription = await self.db.get_subscription_by_id(user_subscription.subscription_id)
if not subscription:
logger.warning(f"Subscription {user_sub.subscription_id} not found")
return False
return
notification_type = None
# Don't send warnings for trial subscriptions
if subscription.is_trial:
return
# Определяем тип уведомления
if user_sub.expires_at <= now:
# Подписка истекла
notification_type = "expired"
elif days_until_expiry <= 0 and hours_until_expiry <= 24:
# Истекает сегодня
notification_type = "expires_today"
elif days_until_expiry == 1:
# Истекает завтра
notification_type = "expires_tomorrow"
elif days_until_expiry == self.WARNING_DAYS:
# Предупреждение за 2 дня
notification_type = "warning"
elif urgent and days_until_expiry <= 1:
# Срочное уведомление
notification_type = "urgent"
message = self._format_expiry_message(subscription.name, days_left, user.language)
if notification_type:
return await self._send_notification(user, user_sub, subscription, notification_type)
return False
await self.bot.send_message(user.telegram_id, message)
logger.info(f"Sent expiry warning to user {user.telegram_id} for subscription {subscription.name}")
except Exception as e:
logger.error(f"Error checking subscription {user_sub.id}: {e}")
return False
logger.error(f"Error sending expiry warning: {e}")
async def _send_notification(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> bool:
"""Отправить уведомление пользователю"""
try:
# Проверяем, не является ли подписка тестовой (для тестовых другая логика)
if subscription.is_trial and notification_type in ["warning", "expires_tomorrow"]:
# Для тестовых подписок не предлагаем продление
return await self._send_trial_expiry_notification(user, user_sub, subscription, notification_type)
# Формируем текст уведомления
message_text = self._format_notification_message(user, user_sub, subscription, notification_type)
# Формируем клавиатуру
keyboard = self._create_notification_keyboard(user, user_sub, subscription, notification_type)
# Отправляем уведомление
await self.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='Markdown'
)
# Логируем действие
log_user_action(user.telegram_id, f"notification_sent_{notification_type}", f"Sub: {subscription.name}")
logger.info(f"Sent {notification_type} notification to user {user.telegram_id} for subscription {subscription.name}")
return True
except Exception as e:
logger.error(f"Error sending notification to user {user.telegram_id}: {e}")
return False
async def _send_trial_expiry_notification(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> bool:
"""Отправить уведомление об истечении тестовой подписки"""
try:
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
if notification_type == "expires_today" or hours_until_expiry <= 24:
message_text = (
f"⏰ *Ваша тестовая подписка истекает сегодня!*\n\n"
f"📋 Подписка: *{subscription.name}*\n"
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
f"💡 Чтобы продолжить пользоваться сервисом, приобретите полную подписку!"
)
elif notification_type == "expires_tomorrow" or days_until_expiry == 1:
message_text = (
f"⚠️ *Ваша тестовая подписка истекает завтра!*\n\n"
f"📋 Подписка: *{subscription.name}*\n"
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n\n"
f"💡 Не забудьте приобрести полную подписку, чтобы продолжить пользоваться сервисом!"
)
def _format_expiry_message(self, subscription_name: str, days_left: int, language: str = 'ru') -> str:
"""Format expiry warning message"""
if language == 'ru':
if days_left <= 0:
return f"⚠️ Ваша подписка '{subscription_name}' истекла!\n\nДля продления перейдите в раздел 'Мои подписки'."
elif days_left == 1:
return f"⚠️ Ваша подписка '{subscription_name}' истекает завтра!\n\nНе забудьте продлить её в разделе 'Мои подписки'."
else:
return False
# Клавиатура для тестовой подписки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="💳 Купить подписку",
callback_data="buy_subscription"
)],
[InlineKeyboardButton(
text="💰 Пополнить баланс",
callback_data="topup_balance"
)],
[InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="main_menu"
)]
])
await self.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode='Markdown'
)
log_user_action(user.telegram_id, f"trial_notification_sent_{notification_type}", f"Sub: {subscription.name}")
logger.info(f"Sent trial {notification_type} notification to user {user.telegram_id}")
return True
except Exception as e:
logger.error(f"Error sending trial notification to user {user.telegram_id}: {e}")
return False
def _format_notification_message(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> str:
"""Форматировать текст уведомления"""
now = datetime.utcnow()
days_until_expiry = (user_sub.expires_at - now).days
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
base_info = (
f"📋 Подписка: *{subscription.name}*\n"
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n"
f"💰 Цена продления: *{subscription.price} руб.*"
)
if notification_type == "expired":
return (
f"❌ *Ваша подписка истекла!*\n\n"
f"{base_info}\n\n"
f"🔄 Продлите подписку, чтобы продолжить пользоваться сервисом."
)
elif notification_type == "expires_today" or notification_type == "urgent":
return (
f"⏰ *Ваша подписка истекает сегодня!*\n\n"
f"{base_info}\n"
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
f"🔄 Продлите подписку прямо сейчас!"
)
elif notification_type == "expires_tomorrow":
return (
f"⚠️ *Ваша подписка истекает завтра!*\n\n"
f"{base_info}\n\n"
f"🔄 Рекомендуем продлить подписку заранее."
)
elif notification_type == "warning":
return (
f"📢 *Напоминание о подписке*\n\n"
f"{base_info}\n"
f"⏳ Осталось: *{days_until_expiry} дней*\n\n"
f"💡 Не забудьте продлить подписку вовремя!"
)
return f"⚠️ Ваша подписка '{subscription_name}' истекает через {days_left} дн.!\n\nРекомендуем продлить её заранее в разделе 'Мои подписки'."
else:
return f"🔔 Уведомление о подписке *{subscription.name}*"
def _create_notification_keyboard(self, user: User, user_sub: UserSubscription,
subscription: Subscription, notification_type: str) -> InlineKeyboardMarkup:
"""Создать клавиатуру для уведомления"""
buttons = []
# Кнопка продления (только для не-тестовых подписок)
if not subscription.is_trial:
if user.balance >= subscription.price:
buttons.append([InlineKeyboardButton(
text=f"🔄 Продлить за {subscription.price} руб.",
callback_data=f"extend_sub_{user_sub.id}"
)])
if days_left <= 0:
return f"⚠️ Your subscription '{subscription_name}' has expired!\n\nTo renew, go to 'My Subscriptions'."
elif days_left == 1:
return f"⚠️ Your subscription '{subscription_name}' expires tomorrow!\n\nDon't forget to renew it in 'My Subscriptions'."
else:
buttons.append([InlineKeyboardButton(
text=f"💰 Пополнить баланс (нужно {subscription.price - user.balance} руб.)",
callback_data="topup_balance"
)])
# Кнопка покупки новой подписки
buttons.append([InlineKeyboardButton(
text="💳 Купить подписку",
callback_data="buy_subscription"
)])
# Кнопка "Мои подписки"
buttons.append([InlineKeyboardButton(
text="📋 Мои подписки",
callback_data="my_subscriptions"
)])
# Кнопка главного меню
buttons.append([InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="main_menu"
)])
return InlineKeyboardMarkup(inline_keyboard=buttons)
async def check_single_user(self, user_id: int) -> List[NotificationResult]:
"""Проверить подписки конкретного пользователя (для тестирования)"""
results = []
try:
user = await self.db.get_user_by_telegram_id(user_id)
if not user:
return [NotificationResult(False, user_id, "User not found")]
user_subs = await self.db.get_user_subscriptions(user_id)
for user_sub in user_subs:
if user_sub.is_active:
try:
sent = await self._check_and_notify_subscription(user, user_sub)
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
sub_name = subscription.name if subscription else "Unknown"
results.append(NotificationResult(
success=sent,
user_id=user_id,
message=f"Subscription: {sub_name}, Sent: {sent}"
))
except Exception as e:
results.append(NotificationResult(
success=False,
user_id=user_id,
message=f"Error checking subscription {user_sub.id}",
error=str(e)
))
except Exception as e:
results.append(NotificationResult(
success=False,
user_id=user_id,
message="Error checking user",
error=str(e)
))
return results
async def get_service_status(self) -> dict:
"""Получить статус сервиса"""
return {
"is_running": self.is_running,
"check_interval": self.CHECK_INTERVAL,
"daily_check_hour": self.DAILY_CHECK_HOUR,
"warning_days": self.WARNING_DAYS,
"last_check": datetime.utcnow().isoformat() if self.is_running else None
}
return f"⚠️ Your subscription '{subscription_name}' expires in {days_left} days!\n\nWe recommend renewing it in advance in 'My Subscriptions'."
async def force_daily_check(self):
"""Принудительно запустить ежедневную проверку"""
logger.info("Force starting daily check")
await self._daily_check()
async def deactivate_expired_subscriptions(self):
"""Деактивировать истекшие подписки"""
"""Force daily check and cleanup"""
try:
now = datetime.utcnow()
logger.info("Starting daily check and cleanup")
# Deactivate expired subscriptions
deactivated_count = await self.deactivate_expired_subscriptions()
# Send final expiry notifications
await self._send_final_expiry_notifications()
logger.info(f"Daily check completed. Deactivated {deactivated_count} expired subscriptions")
except Exception as e:
logger.error(f"Error in force_daily_check: {e}")
async def deactivate_expired_subscriptions(self) -> int:
"""Deactivate expired subscriptions"""
try:
count = 0
all_users = await self.db.get_all_users()
deactivated_count = 0
for user in all_users:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
if user_sub.is_active and user_sub.expires_at <= now:
# Деактивируем подписку
if user_sub.is_active and user_sub.expires_at < datetime.utcnow():
user_sub.is_active = False
await self.db.update_user_subscription(user_sub)
count += 1
# Деактивируем в RemnaWave если API доступно
# Also try to deactivate in RemnaWave if possible
if self.api and user_sub.short_uuid:
try:
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
if remna_user_details:
user_uuid = remna_user_details.get('uuid')
if user_uuid:
# Блокируем пользователя в RemnaWave
await self.api.update_user(user_uuid, {"enable": False})
logger.info(f"Disabled user {user_uuid} in RemnaWave")
user_data = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
if user_data and user_data.get('uuid'):
await self.api.update_user(user_data['uuid'], {'status': 'EXPIRED'})
except Exception as e:
logger.error(f"Failed to disable user in RemnaWave: {e}")
deactivated_count += 1
log_user_action(user.telegram_id, "subscription_expired", f"SubID: {user_sub.id}")
logger.info(f"Deactivated {deactivated_count} expired subscriptions")
return deactivated_count
logger.warning(f"Could not deactivate user in RemnaWave: {e}")
return count
except Exception as e:
logger.error(f"Error deactivating expired subscriptions: {e}")
return 0
async def _send_final_expiry_notifications(self):
"""Send final notifications for just-expired subscriptions"""
try:
# Get subscriptions that expired today
all_users = await self.db.get_all_users()
for user in all_users:
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
for user_sub in user_subs:
# Check if subscription expired today (within last 24 hours)
time_since_expiry = datetime.utcnow() - user_sub.expires_at
if (time_since_expiry.total_seconds() > 0 and
time_since_expiry.total_seconds() <= 86400): # 24 hours
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
if subscription and not subscription.is_trial:
message = self._format_expiry_message(subscription.name, 0, user.language)
await self.bot.send_message(user.telegram_id, message)
except Exception as e:
logger.error(f"Error sending final expiry notifications: {e}")
async def check_single_user(self, user_id: int):
"""Check subscriptions for a single user (for testing)"""
try:
results = []
user = await self.db.get_user_by_telegram_id(user_id)
if not user:
results.append({
'success': False,
'message': f'User {user_id} not found',
'error': None
})
return results
# Get user subscriptions
user_subs = await self.db.get_user_subscriptions(user_id)
if not user_subs:
results.append({
'success': True,
'message': f'User {user_id} has no subscriptions',
'error': None
})
return results
for user_sub in user_subs:
try:
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
days_left = (user_sub.expires_at - datetime.utcnow()).days
if days_left <= self.config.MONITOR_WARNING_DAYS:
# Send test notification
message = self._format_expiry_message(subscription.name, days_left, user.language)
await self.bot.send_message(user_id, f"[ТЕСТ] {message}")
results.append({
'success': True,
'message': f'Sent warning for subscription "{subscription.name}" (expires in {days_left} days)',
'error': None
})
else:
results.append({
'success': True,
'message': f'Subscription "{subscription.name}" is OK (expires in {days_left} days)',
'error': None
})
except Exception as e:
results.append({
'success': False,
'message': f'Error checking subscription ID {user_sub.id}',
'error': str(e)
})
return results
except Exception as e:
return [{
'success': False,
'message': f'Error checking user {user_id}',
'error': str(e)
}]
async def get_service_status(self) -> dict:
"""Get service status information"""
return {
'is_running': self.is_running,
'check_interval': self.config.MONITOR_CHECK_INTERVAL,
'daily_check_hour': self.config.MONITOR_DAILY_CHECK_HOUR,
'warning_days': self.config.MONITOR_WARNING_DAYS,
'last_check': datetime.now().strftime("%Y-%m-%d %H:%M:%S") if self.is_running else None
}
# Функция для инициализации и запуска сервиса
async def create_subscription_monitor(bot: Bot, db: Database, config: Config,
api: Optional[RemnaWaveAPI] = None) -> SubscriptionMonitorService:
"""Создать и настроить сервис мониторинга подписок"""
service = SubscriptionMonitorService(bot, db, config, api)
return service
# Пример использования в основном файле бота
"""
from subscription_monitor import create_subscription_monitor
async def main():
# Инициализация бота, базы данных, конфига
bot = Bot(token=config.BOT_TOKEN)
db = Database(config.DATABASE_URL)
api = RemnaWaveAPI(config.REMNAWAVE_API_URL, config.REMNAWAVE_API_KEY)
# Создание и запуск сервиса мониторинга
monitor_service = await create_subscription_monitor(bot, db, config, api)
await monitor_service.start()
try:
# Запуск бота
await dp.start_polling(bot)
finally:
# Остановка сервиса при завершении
await monitor_service.stop()
if __name__ == "__main__":
asyncio.run(main())
"""
async def create_subscription_monitor(bot, db: Database, config, api: RemnaWaveAPI = None) -> SubscriptionMonitorService:
"""Create and return subscription monitor service"""
return SubscriptionMonitorService(bot, db, config, api)
+29
View File
@@ -64,6 +64,7 @@ TRANSLATIONS = {
'subscription_expires': 'Истекает: {date}',
'subscription_expired': 'Истекла',
'subscription_active': 'Активна до: {date}',
'your_subscriptions': '👀 Список ваших подписок:',
# Payments
'payment_created': '✅ Заявка на пополнение создана.\nОбратитесь к @{support} для оплаты.',
@@ -80,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': '❌ Недостаточно средств на балансе',
@@ -196,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:',
+506 -239
View File
@@ -1,191 +1,131 @@
import re
import uuid
import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
import logging
from typing import Tuple, Optional, Dict, Any
from database import Database
from database import ReferralProgram, ReferralEarning # ДОБАВЛЕНО: импорт моделей
logger = logging.getLogger(__name__)
def generate_username() -> str:
"""Generate random username for RemnaWave"""
return f"user_{secrets.token_hex(8)}"
def generate_password() -> str:
"""Generate random password"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(12))
def generate_promocode() -> str:
"""Generate random promocode"""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
def is_valid_email(email: str) -> bool:
"""Validate email format"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def is_valid_amount(amount_str: str) -> Tuple[bool, float]:
"""Validate and parse amount"""
def is_valid_amount(text: str) -> Tuple[bool, float]:
"""Validate amount input"""
try:
amount = float(amount_str.replace(',', '.'))
# Remove spaces and replace comma with dot
text = text.strip().replace(' ', '').replace(',', '.')
amount = float(text)
# Check if amount is positive and reasonable
if amount <= 0:
return False, 0
if amount > 100000: # Max amount limit
return False, 0
return False, 0.0
if amount > 1000000: # Max 1M rubles
return False, 0.0
# Round to 2 decimal places
amount = round(amount, 2)
return True, amount
except ValueError:
return False, 0
def format_date(date: datetime, lang: str = 'ru') -> str:
"""Format date for display"""
if lang == 'ru':
months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
]
return f"{date.day} {months[date.month-1]} {date.year}"
else:
return date.strftime("%B %d, %Y")
def format_datetime(date: datetime, lang: str = 'ru') -> str:
"""Format datetime for display"""
if lang == 'ru':
return date.strftime("%d.%m.%Y %H:%M")
else:
return date.strftime("%m/%d/%Y %H:%M")
def calculate_expiry_date(days: int) -> str:
"""Calculate expiry date in ISO format"""
expiry = datetime.utcnow() + timedelta(days=days)
return expiry.isoformat() + 'Z'
def parse_telegram_id(text: str) -> Optional[int]:
"""Parse Telegram ID from text"""
try:
telegram_id = int(text.strip())
if telegram_id > 0:
return telegram_id
except ValueError:
pass
return None
def format_traffic(gb: int, lang: str = 'ru') -> str:
"""Format traffic limit for display"""
if gb == 0:
return "Безлимитный" if lang == 'ru' else "Unlimited"
else:
return f"{gb} ГБ" if lang == 'ru' else f"{gb} GB"
def paginate_list(items: List[Any], page: int, per_page: int = 10) -> Tuple[List[Any], int]:
"""Paginate list of items"""
total_pages = (len(items) + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
return items[start_idx:end_idx], total_pages
def escape_markdown(text: str) -> str:
"""Escape markdown special characters"""
special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
for char in special_chars:
text = text.replace(char, f'\\{char}')
return text
def truncate_text(text: str, max_length: int = 4000) -> str:
"""Truncate text to fit Telegram message limits"""
if len(text) <= max_length:
return text
return text[:max_length-3] + "..."
def validate_squad_uuid(uuid_str: str) -> bool:
"""Validate UUID format"""
try:
uuid.UUID(uuid_str)
return True
except ValueError:
return False
def format_subscription_info(subscription: Dict[str, Any], lang: str = 'ru') -> str:
"""Format subscription information for display"""
from translations import t
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
info = t('subscription_info', lang,
name=subscription['name'],
price=subscription['price'],
days=subscription['duration_days'],
traffic=traffic,
description=subscription.get('description', '')
)
return info
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
expires_at: datetime, lang: str = 'ru') -> str:
"""Format user subscription information"""
from translations import t
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
# Check if expired
now = datetime.utcnow()
if expires_at < now:
status = t('subscription_expired', lang)
else:
status = t('subscription_active', lang, date=format_date(expires_at, lang))
info = f"📋 {subscription['name']}\n"
info += f"{subscription['duration_days']} дней\n" if lang == 'ru' else f"{subscription['duration_days']} days\n"
info += f"📊 {traffic}\n"
info += f"🕒 {status}\n"
if subscription.get('description'):
info += f"\n{subscription['description']}"
return info
except (ValueError, TypeError):
return False, 0.0
def validate_promocode_format(code: str) -> bool:
"""Validate promocode format"""
if not code:
return False
# Remove spaces and convert to uppercase
code = code.strip().upper()
# Check length
if len(code) < 3 or len(code) > 20:
return False
if not re.match(r'^[A-Z0-9]+$', code.upper()):
# Check that code contains only letters and numbers
if not code.replace('_', '').isalnum():
return False
return True
def calculate_discount(original_price: float, promocode: Dict[str, Any]) -> float:
"""Calculate discount amount"""
if promocode.get('discount_percent'):
return original_price * (promocode['discount_percent'] / 100)
def validate_squad_uuid(uuid: str) -> bool:
"""Validate squad UUID format"""
if not uuid or not isinstance(uuid, str):
return False
uuid = uuid.strip()
# Basic UUID format validation
if len(uuid) < 8:
return False
# UUID should contain only hex characters and hyphens
allowed_chars = set('0123456789abcdefABCDEF-')
if not all(c in allowed_chars for c in uuid):
return False
return True
def parse_telegram_id(text: str) -> Optional[int]:
"""Parse Telegram ID from text"""
try:
# Remove spaces and common prefixes
text = text.strip().replace(' ', '')
# Remove common prefixes
if text.startswith('@'):
text = text[1:]
if text.startswith('id'):
text = text[2:]
# Try to convert to int
telegram_id = int(text)
# Validate that it's a reasonable Telegram ID
if telegram_id <= 0 or telegram_id > 9999999999: # Max 10 digits
return None
return telegram_id
except (ValueError, TypeError):
return None
def generate_username() -> str:
"""Generate random username for RemnaWave"""
prefix = "user_"
random_part = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(8))
return prefix + random_part
def generate_password() -> str:
"""Generate random password for RemnaWave"""
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
def calculate_expiry_date(days: int) -> str:
"""Calculate expiry date for subscription"""
expiry_date = datetime.now() + timedelta(days=days)
return expiry_date.isoformat() + 'Z'
def format_datetime(dt: datetime, language: str = 'ru') -> str:
"""Format datetime for display"""
if not dt:
return "N/A"
if language == 'ru':
return dt.strftime('%d.%m.%Y %H:%M')
else:
return min(promocode.get('discount_amount', 0), original_price)
return dt.strftime('%Y-%m-%d %H:%M')
def format_payment_status(status: str, lang: str = 'ru') -> str:
"""Format payment status for display"""
status_map = {
'pending': 'В ожидании' if lang == 'ru' else 'Pending',
'completed': 'Завершен' if lang == 'ru' else 'Completed',
'cancelled': 'Отменен' if lang == 'ru' else 'Cancelled',
'failed': 'Ошибка' if lang == 'ru' else 'Failed'
}
return status_map.get(status, status)
def clean_phone_number(phone: str) -> str:
"""Clean and format phone number"""
# Remove all non-digit characters
digits = re.sub(r'\D', '', phone)
def format_date(dt: datetime, language: str = 'ru') -> str:
"""Format date for display"""
if not dt:
return "N/A"
# Handle Russian phone numbers
if digits.startswith('8') and len(digits) == 11:
digits = '7' + digits[1:]
elif digits.startswith('9') and len(digits) == 10:
digits = '7' + digits
return digits
if language == 'ru':
return dt.strftime('%d.%m.%Y')
else:
return dt.strftime('%Y-%m-%d')
def format_bytes(bytes_value: int) -> str:
"""Format bytes to human readable format"""
@@ -200,83 +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]}"
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 log_user_action(telegram_id: int, action: str, details: str = None):
"""Log user action for audit"""
import logging
logger = logging.getLogger(__name__)
log_message = f"Admin action by {telegram_id}: {action}"
if details:
log_message += f" - {details}"
logger.info(log_message)
def format_subscription_status(expires_at: datetime, lang: str = 'ru') -> str:
"""Format subscription status with emoji"""
now = datetime.utcnow()
if expires_at < now:
return "❌ Истекла" if lang == 'ru' else "❌ Expired"
days_left = (expires_at - now).days
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"
else:
return f"✅ Активна ({days_left} дней)" if lang == 'ru' else f"✅ Active ({days_left} days)"
return f"{value:.2f} {units[unit_index]}"
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'
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 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"
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 = ""
ADMIN_ADD_BALANCE_USER = "admin_add_balance_user"
ADMIN_ADD_BALANCE_AMOUNT = "admin_add_balance_amount"
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']}"
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 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_ACTION: {user_id} - {action}" + (f" - {details}" if details else ""))
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")
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 ""
if bot_username.startswith('@'):
bot_username = bot_username[1:]
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"👥 Invited: {stats['total_referrals']}\n"
f"✅ Active: {stats['active_referrals']}\n"
f"💰 Earned: ${stats['total_earned']:.2f}")