Merge branch 'main' into my-fix

This commit is contained in:
PEDZEO
2025-09-23 16:02:16 +03:00
committed by GitHub
20 changed files with 628 additions and 68 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -333,6 +333,35 @@ async def show_campaign_detail(
f"• Выдано баланса: <b>{texts.format_price(stats['balance_issued'])}</b>"
)
text.append(f"• Выдано подписок: <b>{stats['subscription_issued']}</b>")
text.append(
f"• Доход: <b>{texts.format_price(stats['total_revenue_kopeks'])}</b>"
)
text.append(
"• Получили триал: "
f"<b>{stats['trial_users_count']}</b>"
f" (активно: {stats['active_trials_count']})"
)
text.append(
"• Конверсий в оплату: "
f"<b>{stats['conversion_count']}</b>"
f" / пользователей с оплатой: {stats['paid_users_count']}"
)
text.append(
"• Конверсия в оплату: "
f"<b>{stats['conversion_rate']:.1f}%</b>"
)
text.append(
"• Конверсия триала: "
f"<b>{stats['trial_conversion_rate']:.1f}%</b>"
)
text.append(
"• Средний доход на пользователя: "
f"<b>{texts.format_price(stats['avg_revenue_per_user_kopeks'])}</b>"
)
text.append(
"• Средний первый платеж: "
f"<b>{texts.format_price(stats['avg_first_payment_kopeks'])}</b>"
)
if stats["last_registration"]:
text.append(
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"

View File

@@ -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": "Зарегистрированные за месяц",

View File

@@ -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)

View File

@@ -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"📊 <b>Статистика пользователя</b>\n\n"
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
@@ -1236,17 +1244,80 @@ async def show_user_statistics(
text += f"• Отсутствует\n"
text += f"\n<b>Реферальная программа:</b>\n"
if user.referred_by_id:
referrer = await get_user_by_id(db, user.referred_by_id)
if referrer:
text += f"• Пришел по реферальной ссылке от <b>{referrer.full_name}</b>\n"
else:
text += f"• Пришел по реферальной ссылке (реферер не найден)\n"
text += "• Пришел по реферальной ссылке (реферер не найден)\n"
if campaign_registration and campaign_registration.campaign:
text += (
"• Дополнительно зарегистрирован через кампанию "
f"<b>{campaign_registration.campaign.name}</b>\n"
)
elif campaign_registration and campaign_registration.campaign:
text += (
"• Регистрация через рекламную кампанию "
f"<b>{campaign_registration.campaign.name}</b>\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"• Реферальный код: <code>{user.referral_code}</code>\n\n"
if campaign_registration and campaign_registration.campaign and campaign_stats:
text += "<b>Рекламная кампания:</b>\n"
text += (
"• Название: "
f"<b>{campaign_registration.campaign.name}</b>"
)
if campaign_registration.campaign.start_parameter:
text += (
" (параметр: "
f"<code>{campaign_registration.campaign.start_parameter}</code>)"
)
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"<b>Доходы от рефералов:</b>\n"

View File

@@ -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", "✅ <b>Доступны</b>"))
@@ -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"<blockquote>{server_line}</blockquote>")
return lines

View File

@@ -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")

View File

@@ -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] = []

View File

@@ -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:

View File

@@ -21,8 +21,10 @@ SERVER_STATUS:
PAGINATION: "Страница {current} из {total}"
PREV_PAGE: "⬅️ Назад"
NEXT_PAGE: "Вперед ➡️"
REFRESH: "🔄 Обновить"
ERROR_SHORT: "Не удалось получить данные"
NOT_CONFIGURED: "Функция недоступна."
UPDATED_AT: "⏱ Обновлено: {time}"
RULES_TEXT: |
Правила сервиса Remnawave:

View File

@@ -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": "📊 <b>Server status</b>",
"SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}",
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Offline</b>",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\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",

View File

@@ -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": "📊 <b>Статус серверов</b>",
"SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}",
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Недоступны</b>",
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",

View File

@@ -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🛟 <b>Поддержка RemnaWave</b>\n\n"
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"

View File

@@ -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})"

98
docker-compose.local.yml Normal file
View File

@@ -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

View File

@@ -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": "👥 <b>User promo group</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",

View File

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