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": "Вложений нет.",