mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-19 19:01:12 +00:00
Merge branch 'main' into my-fix
This commit is contained in:
6
.github/workflows/docker-hub.yml
vendored
6
.github/workflows/docker-hub.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/docker-registry.yml
vendored
6
.github/workflows/docker-registry.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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')}"
|
||||
|
||||
@@ -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": "Зарегистрированные за месяц",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,8 +21,10 @@ SERVER_STATUS:
|
||||
PAGINATION: "Страница {current} из {total}"
|
||||
PREV_PAGE: "⬅️ Назад"
|
||||
NEXT_PAGE: "Вперед ➡️"
|
||||
REFRESH: "🔄 Обновить"
|
||||
ERROR_SHORT: "Не удалось получить данные"
|
||||
NOT_CONFIGURED: "Функция недоступна."
|
||||
UPDATED_AT: "⏱ Обновлено: {time}"
|
||||
|
||||
RULES_TEXT: |
|
||||
Правила сервиса Remnawave:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
98
docker-compose.local.yml
Normal 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
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "Вложений нет.",
|
||||
|
||||
Reference in New Issue
Block a user