diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 219a42f9..aa4c23c1 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.7-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index d6544884..12796fc9 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.3.7-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)" + VERSION="v2.3.8-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index e615be5b..fde6f796 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.3.7" +ARG VERSION="v2.3.8" ARG BUILD_DATE ARG VCS_REF diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 40313434..6f9e485c 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -9,6 +9,12 @@ from sqlalchemy.orm import selectinload from app.database.models import ( AdvertisingCampaign, AdvertisingCampaignRegistration, + Subscription, + SubscriptionConversion, + SubscriptionStatus, + Transaction, + TransactionType, + User, ) logger = logging.getLogger(__name__) @@ -157,6 +163,19 @@ async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bo return True +async def get_campaign_registration_by_user( + db: AsyncSession, + user_id: int, +) -> Optional[AdvertisingCampaignRegistration]: + result = await db.execute( + select(AdvertisingCampaignRegistration) + .options(selectinload(AdvertisingCampaignRegistration.campaign)) + .where(AdvertisingCampaignRegistration.user_id == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + async def record_campaign_registration( db: AsyncSession, *, @@ -197,6 +216,11 @@ async def get_campaign_statistics( db: AsyncSession, campaign_id: int, ) -> Dict[str, Optional[int]]: + registrations_query = select(AdvertisingCampaignRegistration.user_id).where( + AdvertisingCampaignRegistration.campaign_id == campaign_id + ) + registrations_subquery = registrations_query.subquery() + result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), @@ -207,6 +231,8 @@ async def get_campaign_statistics( ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) count, total_balance, last_registration = result.one() + count = count or 0 + total_balance = total_balance or 0 subscription_count_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( @@ -216,12 +242,215 @@ async def get_campaign_statistics( ) ) ) + subscription_bonuses_issued = subscription_count_result.scalar() or 0 + + deposits_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + deposits_total = deposits_result.scalar() or 0 + + trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + ) + ) + trial_users_count = trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + ) + active_trials_count = active_trials_result.scalar() or 0 + + conversions_result = await db.execute( + select(func.count(func.distinct(SubscriptionConversion.user_id))).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + conversion_count = conversions_result.scalar() or 0 + + paid_users_result = await db.execute( + select(func.count(User.id)).where( + User.id.in_(select(registrations_subquery.c.user_id)), + User.has_had_paid_subscription.is_(True), + ) + ) + paid_users_from_flag = paid_users_result.scalar() or 0 + + conversions_rows = await db.execute( + select( + SubscriptionConversion.user_id, + SubscriptionConversion.first_payment_amount_kopeks, + SubscriptionConversion.converted_at, + ) + .where( + SubscriptionConversion.user_id.in_( + select(registrations_subquery.c.user_id) + ) + ) + .order_by(SubscriptionConversion.converted_at) + ) + conversion_entries = conversions_rows.all() + + subscription_payments_rows = await db.execute( + select( + Transaction.user_id, + Transaction.amount_kopeks, + Transaction.created_at, + ) + .where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed.is_(True), + ) + .order_by(Transaction.user_id, Transaction.created_at) + ) + subscription_payments = subscription_payments_rows.all() + + subscription_payments_total = 0 + paid_users_from_transactions = set() + conversion_user_ids = set() + first_payment_amount_by_user: Dict[int, int] = {} + first_payment_time_by_user: Dict[int, Optional[datetime]] = {} + + for user_id, amount_kopeks, converted_at in conversion_entries: + conversion_user_ids.add(user_id) + amount_value = int(amount_kopeks or 0) + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = converted_at + + for user_id, amount_kopeks, created_at in subscription_payments: + amount_value = int(amount_kopeks or 0) + subscription_payments_total += amount_value + paid_users_from_transactions.add(user_id) + + if user_id not in first_payment_amount_by_user: + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + else: + existing_time = first_payment_time_by_user.get(user_id) + if existing_time is None and created_at is not None: + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + elif ( + existing_time is not None + and created_at is not None + and created_at < existing_time + ): + first_payment_amount_by_user[user_id] = amount_value + first_payment_time_by_user[user_id] = created_at + + total_revenue = deposits_total + subscription_payments_total + + paid_user_ids = set(paid_users_from_transactions) + paid_user_ids.update(conversion_user_ids) + paid_users_count = max(len(paid_user_ids), paid_users_from_flag) + + conversion_count = conversion_count or len(paid_user_ids) + if conversion_count < len(paid_user_ids): + conversion_count = len(paid_user_ids) + + avg_first_payment = 0 + if first_payment_amount_by_user: + avg_first_payment = int( + sum(first_payment_amount_by_user.values()) + / len(first_payment_amount_by_user) + ) + + conversion_rate = 0.0 + if count: + conversion_rate = round((paid_users_count / count) * 100, 1) + + trial_conversion_rate = 0.0 + if trial_users_count: + trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1) + + avg_revenue_per_user = 0 + if count: + avg_revenue_per_user = int(total_revenue / count) + + deposits_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.user_id.in_(select(registrations_subquery.c.user_id)), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + total_revenue = deposits_result.scalar() or 0 + + trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + ) + ) + trial_users_count = trials_result.scalar() or 0 + + active_trials_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))).where( + Subscription.user_id.in_(select(registrations_subquery.c.user_id)), + Subscription.is_trial.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + ) + active_trials_count = active_trials_result.scalar() or 0 + + conversions_result = await db.execute( + select(func.count(func.distinct(SubscriptionConversion.user_id))).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + conversion_count = conversions_result.scalar() or 0 + + paid_users_result = await db.execute( + select(func.count(User.id)).where( + User.id.in_(select(registrations_subquery.c.user_id)), + User.has_had_paid_subscription.is_(True), + ) + ) + paid_users_count = paid_users_result.scalar() or 0 + + avg_first_payment_result = await db.execute( + select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where( + SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)) + ) + ) + avg_first_payment = int(avg_first_payment_result.scalar() or 0) + + conversion_rate = 0.0 + if count: + conversion_rate = round((paid_users_count / count) * 100, 1) + + trial_conversion_rate = 0.0 + if trial_users_count: + trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1) + + avg_revenue_per_user = 0 + if count: + avg_revenue_per_user = int(total_revenue / count) return { - "registrations": count or 0, - "balance_issued": total_balance or 0, - "subscription_issued": subscription_count_result.scalar() or 0, + "registrations": count, + "balance_issued": total_balance, + "subscription_issued": subscription_bonuses_issued, "last_registration": last_registration, + "total_revenue_kopeks": total_revenue, + "trial_users_count": trial_users_count, + "active_trials_count": active_trials_count, + "conversion_count": conversion_count, + "paid_users_count": paid_users_count, + "conversion_rate": conversion_rate, + "trial_conversion_rate": trial_conversion_rate, + "avg_revenue_per_user_kopeks": avg_revenue_per_user, + "avg_first_payment_kopeks": avg_first_payment, } diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 22ff8aac..f6b9b325 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -333,6 +333,35 @@ async def show_campaign_detail( f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" ) text.append(f"• Выдано подписок: {stats['subscription_issued']}") + text.append( + f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}" + ) + text.append( + "• Получили триал: " + f"{stats['trial_users_count']}" + f" (активно: {stats['active_trials_count']})" + ) + text.append( + "• Конверсий в оплату: " + f"{stats['conversion_count']}" + f" / пользователей с оплатой: {stats['paid_users_count']}" + ) + text.append( + "• Конверсия в оплату: " + f"{stats['conversion_rate']:.1f}%" + ) + text.append( + "• Конверсия триала: " + f"{stats['trial_conversion_rate']:.1f}%" + ) + text.append( + "• Средний доход на пользователя: " + f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}" + ) + text.append( + "• Средний первый платеж: " + f"{texts.format_price(stats['avg_first_payment_kopeks'])}" + ) if stats["last_registration"]: text.append( f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 83880515..cc79292b 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -9,7 +9,13 @@ from sqlalchemy import select, func, and_, or_ from app.config import settings from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, BroadcastHistory +from app.database.models import ( + User, + UserStatus, + Subscription, + SubscriptionStatus, + BroadcastHistory, +) from app.keyboards.admin import ( get_admin_messages_keyboard, get_broadcast_target_keyboard, get_custom_criteria_keyboard, get_broadcast_history_keyboard, @@ -260,14 +266,21 @@ async def select_broadcast_target( state: FSMContext, db: AsyncSession ): - target = callback.data.split('_')[-1] - + raw_target = callback.data[len("broadcast_"):] + target_aliases = { + "no_sub": "no", + } + target = target_aliases.get(raw_target, raw_target) + target_names = { "all": "Всем пользователям", "active": "С активной подпиской", - "trial": "С триальной подпиской", + "trial": "С триальной подпиской", "no": "Без подписки", - "expiring": "С истекающей подпиской" + "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", } user_count = await get_target_users_count(db, target) @@ -817,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: async def get_target_users(db: AsyncSession, target: str) -> list: - if target == "all": - return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - elif target == "active": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial] - elif target == "trial": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if user.subscription and user.subscription.is_trial] - elif target == "no": - users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) - return [user for user in users if not user.subscription or not user.subscription.is_active] - elif target == "expiring": - expiring_subs = await get_expiring_subscriptions(db, 3) - return [sub.user for sub in expiring_subs if sub.user] - else: - return [] + users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE) + + if target == "all": + return users + + if target == "active": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and not user.subscription.is_trial + ] + + if target == "trial": + return [ + user + for user in users + if user.subscription and user.subscription.is_trial + ] + + if target == "no": + return [ + user + for user in users + if not user.subscription or not user.subscription.is_active + ] + + if target == "expiring": + expiring_subs = await get_expiring_subscriptions(db, 3) + return [sub.user for sub in expiring_subs if sub.user] + + if target == "expired": + now = datetime.utcnow() + expired_statuses = { + SubscriptionStatus.EXPIRED.value, + SubscriptionStatus.DISABLED.value, + } + expired_users = [] + for user in users: + subscription = user.subscription + if subscription: + if subscription.status in expired_statuses: + expired_users.append(user) + continue + if subscription.end_date <= now and not subscription.is_active: + expired_users.append(user) + continue + elif user.has_had_paid_subscription: + expired_users.append(user) + return expired_users + + if target == "active_zero": + return [ + user + for user in users + if user.subscription + and not user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + if target == "trial_zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_trial + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + if target == "zero": + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and (user.subscription.traffic_used_gb or 0) <= 0 + ] + + return [] async def get_custom_users_count(db: AsyncSession, criteria: str) -> int: @@ -956,7 +1035,12 @@ def get_target_name(target_type: str) -> str: "active": "С активной подпиской", "trial": "С триальной подпиской", "no": "Без подписки", + "sub": "Без подписки", "expiring": "С истекающей подпиской", + "expired": "С истекшей подпиской", + "active_zero": "Активная подписка, трафик 0 ГБ", + "trial_zero": "Триальная подписка, трафик 0 ГБ", + "zero": "Подписка, трафик 0 ГБ", "custom_today": "Зарегистрированные сегодня", "custom_week": "Зарегистрированные за неделю", "custom_month": "Зарегистрированные за месяц", diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index dcaab6d2..2503cd48 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -125,29 +125,28 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: Optional[FSMContext] = None + state: Optional[FSMContext] = None, + ticket_id: Optional[int] = None ): """Показать детали тикета для админа""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - data_str = callback.data or "" - ticket_id = None - try: - if data_str.startswith("admin_view_ticket_"): - ticket_id = int(data_str.replace("admin_view_ticket_", "")) - else: - ticket_id = int(data_str.split("_")[-1]) - except Exception: - ticket_id = None + if ticket_id is None: - texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден."), - show_alert=True - ) - return + try: + ticket_id = int((callback.data or "").split("_")[-1]) + except (ValueError, AttributeError): + texts = get_texts(db_user.language) + await callback.answer( + texts.t("TICKET_NOT_FOUND", "Тикет не найден."), + show_alert=True + ) + return + + if state is None: + state = FSMContext(callback.bot, callback.from_user.id) ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) @@ -391,7 +390,8 @@ async def handle_admin_ticket_reply( async def mark_ticket_as_answered( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): """Отметить тикет как отвеченный""" ticket_id = int(callback.data.replace("admin_mark_answered_", "")) @@ -409,7 +409,7 @@ async def mark_ticket_as_answered( ) # Обновляем сообщение - await view_admin_ticket(callback, db_user, db) + await view_admin_ticket(callback, db_user, db, state) else: texts = get_texts(db_user.language) await callback.answer( @@ -673,13 +673,11 @@ async def handle_admin_block_duration_input( - - - async def unblock_user_in_ticket( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) @@ -713,7 +711,7 @@ async def unblock_user_in_ticket( ) except Exception: pass - await view_admin_ticket(callback, db_user, db) + await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) @@ -721,7 +719,8 @@ async def unblock_user_in_ticket( async def block_user_permanently( callback: types.CallbackQuery, db_user: User, - db: AsyncSession + db: AsyncSession, + state: FSMContext ): if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) @@ -754,7 +753,7 @@ async def block_user_permanently( ) except Exception: pass - await view_admin_ticket(callback, db_user, db) + await view_admin_ticket(callback, db_user, db, state) else: await callback.answer("❌ Ошибка", show_alert=True) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 620e4001..45fe983f 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -8,7 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.states import AdminStates from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import get_user_by_id +from app.database.crud.user import get_user_by_id +from app.database.crud.campaign import ( + get_campaign_registration_by_user, + get_campaign_statistics, +) from app.keyboards.admin import ( get_admin_users_keyboard, get_user_management_keyboard, get_admin_pagination_keyboard, get_confirmation_keyboard, @@ -1214,6 +1218,10 @@ async def show_user_statistics( subscription = profile["subscription"] referral_stats = await get_detailed_referral_stats(db, user.id) + campaign_registration = await get_campaign_registration_by_user(db, user.id) + campaign_stats = None + if campaign_registration: + campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) text = f"📊 Статистика пользователя\n\n" text += f"👤 {user.full_name} (ID: {user.telegram_id})\n\n" @@ -1236,17 +1244,80 @@ async def show_user_statistics( text += f"• Отсутствует\n" text += f"\nРеферальная программа:\n" - + if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" else: - text += f"• Пришел по реферальной ссылке (реферер не найден)\n" + text += "• Пришел по реферальной ссылке (реферер не найден)\n" + if campaign_registration and campaign_registration.campaign: + text += ( + "• Дополнительно зарегистрирован через кампанию " + f"{campaign_registration.campaign.name}\n" + ) + elif campaign_registration and campaign_registration.campaign: + text += ( + "• Регистрация через рекламную кампанию " + f"{campaign_registration.campaign.name}\n" + ) + if campaign_registration.created_at: + text += ( + "• Дата регистрации по кампании: " + f"{campaign_registration.created_at.strftime('%d.%m.%Y %H:%M')}\n" + ) else: - text += f"• Прямая регистрация\n" - + text += "• Прямая регистрация\n" + text += f"• Реферальный код: {user.referral_code}\n\n" + + if campaign_registration and campaign_registration.campaign and campaign_stats: + text += "Рекламная кампания:\n" + text += ( + "• Название: " + f"{campaign_registration.campaign.name}" + ) + if campaign_registration.campaign.start_parameter: + text += ( + " (параметр: " + f"{campaign_registration.campaign.start_parameter})" + ) + text += "\n" + text += ( + "• Всего регистраций: " + f"{campaign_stats['registrations']}\n" + ) + text += ( + "• Суммарный доход: " + f"{settings.format_price(campaign_stats['total_revenue_kopeks'])}\n" + ) + text += ( + "• Получили триал: " + f"{campaign_stats['trial_users_count']}" + f" (активно: {campaign_stats['active_trials_count']})\n" + ) + text += ( + "• Конверсий в оплату: " + f"{campaign_stats['conversion_count']}" + f" (оплативших пользователей: {campaign_stats['paid_users_count']})\n" + ) + text += ( + "• Конверсия в оплату: " + f"{campaign_stats['conversion_rate']:.1f}%\n" + ) + text += ( + "• Конверсия триала: " + f"{campaign_stats['trial_conversion_rate']:.1f}%\n" + ) + text += ( + "• Средний доход на пользователя: " + f"{settings.format_price(campaign_stats['avg_revenue_per_user_kopeks'])}\n" + ) + text += ( + "• Средний первый платеж: " + f"{settings.format_price(campaign_stats['avg_first_payment_kopeks'])}\n" + ) + text += "\n" if referral_stats['invited_count'] > 0: text += f"Доходы от рефералов:\n" diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index bd0663b4..c223dc5a 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import List, Tuple from aiogram import Dispatcher, F, types @@ -105,7 +106,16 @@ def _build_status_message( offline=len(offline_servers), ) - lines.extend(["", summary, ""]) + updated_at = datetime.now().strftime("%H:%M:%S") + + lines.extend( + [ + "", + summary, + texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at), + "", + ] + ) if current_online: lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ Доступны")) @@ -183,7 +193,8 @@ def _format_server_lines( name = server.display_name or server.name flag_prefix = f"{server.flag} " if server.flag else "" - lines.append(f"> {flag_prefix}{name} — {latency_text}") + server_line = f"{flag_prefix}{name} — {latency_text}" + lines.append(f"
{server_line}
") return lines diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 4137a918..7b5f1549 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -568,7 +568,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub") ], [ - InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring") + InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring"), + InlineKeyboardButton(text="🔚 Истекшие", callback_data="broadcast_expired") + ], + [ + InlineKeyboardButton(text="🧊 Активна 0 ГБ", callback_data="broadcast_active_zero"), + InlineKeyboardButton(text="🥶 Триал 0 ГБ", callback_data="broadcast_trial_zero") ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages") diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 27400acb..afc8c61e 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -222,7 +222,14 @@ def get_server_status_keyboard( total_pages: int, ) -> InlineKeyboardMarkup: texts = get_texts(language) - keyboard: list[list[InlineKeyboardButton]] = [] + keyboard: list[list[InlineKeyboardButton]] = [ + [ + InlineKeyboardButton( + text=texts.t("SERVER_STATUS_REFRESH", "🔄 Обновить"), + callback_data=f"server_status_page:{current_page}", + ) + ] + ] if total_pages > 1: nav_row: list[InlineKeyboardButton] = [] diff --git a/app/localization/default_locales/en.yml b/app/localization/default_locales/en.yml index 30db758d..95bb4709 100644 --- a/app/localization/default_locales/en.yml +++ b/app/localization/default_locales/en.yml @@ -21,8 +21,10 @@ SERVER_STATUS: PAGINATION: "Page {current} of {total}" PREV_PAGE: "⬅️ Back" NEXT_PAGE: "Next ➡️" + REFRESH: "🔄 Refresh" ERROR_SHORT: "Failed to fetch data" NOT_CONFIGURED: "Feature is not available." + UPDATED_AT: "⏱ Updated at: {time}" RULES_TEXT: | Remnawave service rules: diff --git a/app/localization/default_locales/ru.yml b/app/localization/default_locales/ru.yml index ca83ca7e..fc173e56 100644 --- a/app/localization/default_locales/ru.yml +++ b/app/localization/default_locales/ru.yml @@ -21,8 +21,10 @@ SERVER_STATUS: PAGINATION: "Страница {current} из {total}" PREV_PAGE: "⬅️ Назад" NEXT_PAGE: "Вперед ➡️" + REFRESH: "🔄 Обновить" ERROR_SHORT: "Не удалось получить данные" NOT_CONFIGURED: "Функция недоступна." + UPDATED_AT: "⏱ Обновлено: {time}" RULES_TEXT: | Правила сервиса Remnawave: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index bd81c8fa..5a9077ac 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -251,8 +251,10 @@ "SERVER_STATUS_OFFLINE": "no response", "SERVER_STATUS_PAGINATION": "Page {current} of {total}", "SERVER_STATUS_PREV_PAGE": "⬅️ Back", + "SERVER_STATUS_REFRESH": "🔄 Refresh", "SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})", "SERVER_STATUS_TITLE": "📊 Server status", + "SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}", "SERVER_STATUS_UNAVAILABLE": "❌ Offline", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Confirm traffic change\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "SWITCH_TRAFFIC_INFO": "\n🔄 Switch traffic limit\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 Important:\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ac0e159f..210b93ff 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -238,8 +238,10 @@ "SERVER_STATUS_OFFLINE": "нет ответа", "SERVER_STATUS_PAGINATION": "Страница {current} из {total}", "SERVER_STATUS_PREV_PAGE": "⬅️ Назад", + "SERVER_STATUS_REFRESH": "🔄 Обновить", "SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", "SERVER_STATUS_TITLE": "📊 Статус серверов", + "SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}", "SERVER_STATUS_UNAVAILABLE": "❌ Недоступны", "SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик", "SWITCH_TRAFFIC_CONFIRM": "\n🔄 Подтверждение переключения трафика\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n", diff --git a/app/localization/texts.py b/app/localization/texts.py index c8d65666..ccd8eff1 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -44,7 +44,7 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: "TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}", "TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}", "SUPPORT_INFO": ( - "\n🛟 Поддержка RemnaWave\n\n" + "\n🛟 Поддержка\n\n" "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n" "• 🎫 Создать тикет — опишите проблему или вопрос\n" "• 📋 Мои тикеты — статус и переписка\n" diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index cbf3a961..f6ca0d5a 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -66,6 +66,8 @@ def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days) if language == "ru": + if days == 14: + return "14 дней" if days == 30: return "1 месяц" elif days == 60: @@ -79,7 +81,9 @@ def format_period_description(days: int, language: str = "ru") -> str: else: month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев") return f"{days} дней ({months} {month_word})" - else: + else: + if days == 14: + return "14 days" month_word = "month" if months == 1 else "months" return f"{days} days ({months} {month_word})" diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..b21a85c6 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,98 @@ +services: + postgres: + image: postgres:15-alpine + container_name: remnawave_bot_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} + POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - bot_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: remnawave_bot_redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - bot_network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + bot: + build: . + container_name: remnawave_bot + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + environment: + DOCKER_ENV: "true" + DATABASE_MODE: "auto" + POSTGRES_HOST: "postgres" + POSTGRES_PORT: "5432" + POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" + POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" + + REDIS_URL: "redis://redis:6379/0" + + TZ: "Europe/Moscow" + LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + volumes: + # Логи + - ./logs:/app/logs:rw + # Данные приложения (для SQLite в случае переключения) + - ./data:/app/data:rw + - ./locales:/app/locales:rw + # Конфигурация приложения + # - ./app-config.json:/app/app-config.json:ro + # Timezone + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + # Логотип для сообщений + - ./vpn_logo.png:/app/vpn_logo.png:ro + ports: + - "${TRIBUTE_WEBHOOK_PORT:-8081}:8081" + - "${YOOKASSA_WEBHOOK_PORT:-8082}:8082" + networks: + - bot_network + healthcheck: + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +networks: + bot_network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + gateway: 172.20.0.1 diff --git a/locales/en.json b/locales/en.json index 61ad8a8a..e72fd5e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -171,7 +171,7 @@ "NO_TICKETS_ADMIN": "No tickets to display.", "ADMIN_TICKETS_TITLE": "🎫 All support tickets:", "ADMIN_TICKET_REPLY_INPUT": "Enter support reply:", - + "ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!", "TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.", "TICKET_UPDATE_ERROR": "❌ Error updating ticket.", @@ -179,6 +179,19 @@ "TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:", "CLOSE_NOTIFICATION": "❌ Close notification", "NOTIFICATION_CLOSED": "Notification closed.", + "UNBLOCK": "✅ Unblock", + "BLOCK_FOREVER": "🚫 Block permanently", + "BLOCK_BY_TIME": "⏳ Temporary block", + "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", + "TICKET_ATTACHMENTS": "📎 Attachments", + "OPEN_TICKETS": "🔴 Open", + "CLOSED_TICKETS": "🟢 Closed", + "OPEN_TICKETS_HEADER": "🔴 Open tickets", + "CLOSED_TICKETS_HEADER": "🟢 Closed tickets", + "SENDING_ATTACHMENTS": "📎 Sending attachments...", + "NO_ATTACHMENTS": "No attachments.", + "ATTACHMENTS_SENT": "✅ Attachments sent.", + "DELETE_MESSAGE": "🗑 Delete", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", "ADMIN_USER_PROMO_GROUP_TITLE": "👥 User promo group", "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", diff --git a/locales/ru.json b/locales/ru.json index 54cbba08..cb12e4c5 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -70,9 +70,11 @@ "UNBLOCK": "✅ Разблокировать", "BLOCK_FOREVER": "🚫 Заблокировать", "BLOCK_BY_TIME": "⏳ Блокировка по времени", + "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "TICKET_ATTACHMENTS": "📎 Вложения", "OPEN_TICKETS": "🔴 Открытые", "CLOSED_TICKETS": "🟢 Закрытые", + "CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты", "OPEN_TICKETS_HEADER": "🔴 Открытые тикеты", "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", "NO_ATTACHMENTS": "Вложений нет.",