Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d57c9be21 | ||
|
|
fde0f1b135 | ||
|
|
67df164498 | ||
|
|
48e666f605 | ||
|
|
3cb15fa254 | ||
|
|
2100949470 | ||
|
|
c2a475b062 | ||
|
|
585cbe8fb6 | ||
|
|
a243ddcac8 | ||
|
|
357d7caf26 | ||
|
|
2713b3bae5 |
@@ -127,6 +127,126 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete:"))
|
||||
async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, _, camp_id_str, back_page_str = callback.data.split(":", 3)
|
||||
camp_id = int(camp_id_str)
|
||||
back_page = int(back_page_str)
|
||||
except Exception:
|
||||
await callback.answer(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
|
||||
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
|
||||
kb = get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
|
||||
no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
|
||||
async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
# Return to the ad card view
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
|
||||
async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
# After delete, show list page (may shift due to fewer items)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(back_page, total_pages - 1))
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
except Exception:
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
@@ -15,9 +15,15 @@ from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils import get_message_content, send_direct_message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.utils.text_sanitizer import (
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
username_for_display,
|
||||
)
|
||||
|
||||
router = Router(name="admin_user_management_router")
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
@@ -106,7 +112,8 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
||||
|
||||
async def format_user_card(user: User, session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
i18n_instance, lang: str) -> str:
|
||||
i18n_instance, lang: str,
|
||||
referral_service: Optional[ReferralService] = None) -> str:
|
||||
"""Format user information as a detailed card"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
@@ -116,8 +123,16 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
|
||||
# User details
|
||||
na_value = _("admin_user_na_value", default="N/A")
|
||||
user_name = user.first_name or na_value
|
||||
username_display = f"@{user.username}" if user.username else na_value
|
||||
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
||||
user_name = safe_first_name or na_value
|
||||
if user.username:
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
if sanitized_username:
|
||||
username_display = f"@{sanitized_username}"
|
||||
else:
|
||||
username_display = username_for_display(user.username, with_at=False)
|
||||
else:
|
||||
username_display = na_value
|
||||
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
||||
|
||||
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
||||
@@ -177,6 +192,17 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
||||
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
||||
|
||||
# Referral stats
|
||||
if referral_service is not None:
|
||||
try:
|
||||
stats = await referral_service.get_referral_stats(session, user.user_id)
|
||||
invited_count = stats.get('invited_count', 0)
|
||||
purchased_count = stats.get('purchased_count', 0)
|
||||
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
|
||||
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
|
||||
except Exception as e_rs:
|
||||
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting user statistics for {user.user_id}: {e}")
|
||||
|
||||
@@ -224,7 +250,8 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
# Format and send user card
|
||||
try:
|
||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
@@ -482,7 +509,10 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
||||
await callback.answer("User not found", show_alert=True)
|
||||
return
|
||||
|
||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang)
|
||||
from config.settings import Settings as _Settings
|
||||
_settings = _Settings()
|
||||
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
||||
|
||||
try:
|
||||
@@ -556,7 +586,8 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
||||
# Show updated user card
|
||||
user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if user:
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
@@ -664,7 +695,8 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
subscription_service = SubscriptionService(settings, panel_service)
|
||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
|
||||
@@ -22,6 +22,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
@@ -241,10 +242,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(
|
||||
session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
|
||||
+16
-11
@@ -17,6 +17,7 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
||||
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
@@ -152,13 +153,17 @@ async def start_command_handler(message: types.Message,
|
||||
ad_start_param = ad_param_match.group(1)
|
||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc)
|
||||
@@ -177,8 +182,8 @@ async def start_command_handler(message: types.Message,
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_new_user_registration(
|
||||
user_id=user_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
username=sanitized_username,
|
||||
first_name=sanitized_first_name,
|
||||
referred_by_id=referred_by_user_id
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -203,12 +208,12 @@ async def start_command_handler(message: types.Message,
|
||||
is_active_now = False
|
||||
if not is_active_now:
|
||||
update_payload["referred_by_id"] = referred_by_user_id
|
||||
if user.username != db_user.username:
|
||||
update_payload["username"] = user.username
|
||||
if user.first_name != db_user.first_name:
|
||||
update_payload["first_name"] = user.first_name
|
||||
if user.last_name != db_user.last_name:
|
||||
update_payload["last_name"] = user.last_name
|
||||
if sanitized_username != db_user.username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if sanitized_first_name != db_user.first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if sanitized_last_name != db_user.last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
try:
|
||||
|
||||
@@ -155,7 +155,7 @@ async def my_subscription_command_handler(
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Mini-app connect button on top if enabled
|
||||
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -163,6 +163,15 @@ async def my_subscription_command_handler(
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
|
||||
@@ -185,6 +185,9 @@ def get_ads_list_keyboard(
|
||||
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiogram.types import Update, User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class ProfileSyncMiddleware(BaseMiddleware):
|
||||
@@ -24,12 +25,16 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||
if db_user:
|
||||
update_payload: Dict[str, Any] = {}
|
||||
if db_user.username != tg_user.username:
|
||||
update_payload["username"] = tg_user.username
|
||||
if db_user.first_name != tg_user.first_name:
|
||||
update_payload["first_name"] = tg_user.first_name
|
||||
if db_user.last_name != tg_user.last_name:
|
||||
update_payload["last_name"] = tg_user.last_name
|
||||
sanitized_username = sanitize_username(tg_user.username)
|
||||
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
||||
|
||||
if db_user.username != sanitized_username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if db_user.first_name != sanitized_first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if db_user.last_name != sanitized_last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
||||
@@ -42,10 +47,10 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join([
|
||||
tg_user.username or "",
|
||||
tg_user.first_name or "",
|
||||
tg_user.last_name or "",
|
||||
])
|
||||
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
||||
sanitized_first_name or "",
|
||||
sanitized_last_name or "",
|
||||
]).strip()
|
||||
await panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid,
|
||||
{"description": description_text},
|
||||
@@ -62,4 +67,3 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
@@ -189,10 +190,12 @@ class CryptoPayService:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
|
||||
@@ -10,6 +10,10 @@ from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -20,6 +24,17 @@ class NotificationService:
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
@staticmethod
|
||||
def _format_user_display(
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
) -> str:
|
||||
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
@@ -101,9 +116,11 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = first_name or f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
@@ -134,9 +151,10 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
provider_emoji = {
|
||||
"yookassa": "💳",
|
||||
@@ -174,9 +192,10 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
@@ -203,9 +222,10 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
@@ -268,9 +288,11 @@ class NotificationService:
|
||||
_ = lambda k, **kw: self.i18n.gettext(
|
||||
admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = first_name or f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
|
||||
@@ -12,6 +12,7 @@ from .referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -124,10 +125,12 @@ class StarsService:
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
@@ -173,4 +176,3 @@ class StarsService:
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
@@ -184,10 +185,12 @@ class TributeService:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
@@ -250,8 +253,39 @@ class TributeService:
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
# Set all user's subscriptions to expire in 1 day (grace period)
|
||||
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
|
||||
|
||||
_URL_PATTERNS = [
|
||||
re.compile(r"(?i)https?://\S+"),
|
||||
re.compile(r"(?i)www\.\S+"),
|
||||
re.compile(r"(?i)tg://\S+"),
|
||||
re.compile(r"(?i)telegram\.me\S*"),
|
||||
re.compile(r"(?i)t\.me/\+\S*"),
|
||||
re.compile(r"(?i)joinchat\S*"),
|
||||
]
|
||||
|
||||
_OBFUSCATED_DOMAIN_PATTERNS = [
|
||||
re.compile(
|
||||
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
|
||||
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
|
||||
r"[{}\s]*(?:[mм]|rn)".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(r"(?i)t\.me\S*"),
|
||||
]
|
||||
|
||||
_ENGLISH_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)telegram"),
|
||||
re.compile(r"(?i)teleqram"),
|
||||
re.compile(r"(?i)teiegram"),
|
||||
re.compile(r"(?i)teieqram"),
|
||||
re.compile(r"(?i)telegrarn"),
|
||||
re.compile(r"(?i)service"),
|
||||
re.compile(r"(?i)notif(?:ication)?"),
|
||||
re.compile(r"(?i)system"),
|
||||
re.compile(r"(?i)security"),
|
||||
re.compile(r"(?i)safety"),
|
||||
re.compile(r"(?i)support"),
|
||||
re.compile(r"(?i)moderation"),
|
||||
re.compile(r"(?i)review"),
|
||||
re.compile(r"(?i)compliance"),
|
||||
re.compile(r"(?i)abuse"),
|
||||
re.compile(r"(?i)spam"),
|
||||
re.compile(r"(?i)report"),
|
||||
]
|
||||
|
||||
_RUSSIAN_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)телеграм\w*"),
|
||||
re.compile(r"(?i)служебн\w*"),
|
||||
re.compile(r"(?i)уведомлен\w*"),
|
||||
re.compile(r"(?i)поддержк\w*"),
|
||||
re.compile(r"(?i)безопасн\w*"),
|
||||
re.compile(r"(?i)модерац\w*"),
|
||||
re.compile(r"(?i)жалоб\w*"),
|
||||
re.compile(r"(?i)абуз\w*"),
|
||||
]
|
||||
|
||||
_PRE_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"I": "l",
|
||||
"İ": "l",
|
||||
"Q": "g",
|
||||
"@": " ",
|
||||
}
|
||||
)
|
||||
|
||||
_POST_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"і": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "c",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sh",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"_": "_",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALIZED_BANNED_TOKENS = {
|
||||
"tme",
|
||||
"telegram",
|
||||
"teleqram",
|
||||
"teiegram",
|
||||
"teieqram",
|
||||
"telegrarn",
|
||||
"joinchat",
|
||||
"http",
|
||||
"https",
|
||||
"www",
|
||||
"tg",
|
||||
"service",
|
||||
"notification",
|
||||
"system",
|
||||
"security",
|
||||
"safety",
|
||||
"support",
|
||||
"moderation",
|
||||
"review",
|
||||
"compliance",
|
||||
"abuse",
|
||||
"spam",
|
||||
"report",
|
||||
}
|
||||
|
||||
_USERNAME_PLACEHOLDER = "клиент"
|
||||
|
||||
|
||||
def _normalize_for_detection(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
|
||||
normalized = normalized.lower()
|
||||
normalized = "".join(
|
||||
ch for ch in normalized if unicodedata.category(ch) != "Mn"
|
||||
)
|
||||
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
|
||||
normalized = normalized.replace("rn", "m")
|
||||
|
||||
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
|
||||
normalized = re.sub(pattern, "", normalized)
|
||||
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _remove_patterns(value: str) -> str:
|
||||
updated = value
|
||||
for pattern in (
|
||||
_URL_PATTERNS
|
||||
+ _OBFUSCATED_DOMAIN_PATTERNS
|
||||
+ _ENGLISH_SERVICE_PATTERNS
|
||||
+ _RUSSIAN_SERVICE_PATTERNS
|
||||
):
|
||||
updated = pattern.sub(" ", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _finalize(value: str) -> Optional[str]:
|
||||
compacted = re.sub(r"\s+", " ", value)
|
||||
compacted = compacted.strip(" \t\r\n-_.,/\\")
|
||||
compacted = compacted.strip()
|
||||
if not compacted:
|
||||
return None
|
||||
|
||||
normalized = _normalize_for_detection(compacted)
|
||||
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
|
||||
return None
|
||||
return compacted
|
||||
|
||||
|
||||
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.replace("@", " ")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def sanitize_username(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
clean = clean.lstrip("@")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
|
||||
sanitized = sanitize_username(username)
|
||||
if not sanitized:
|
||||
return _USERNAME_PLACEHOLDER
|
||||
return f"@{sanitized}" if with_at else sanitized
|
||||
|
||||
|
||||
def display_name_or_fallback(
|
||||
first_name: Optional[str],
|
||||
fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
sanitized = sanitize_display_name(first_name)
|
||||
if sanitized:
|
||||
return sanitized
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
return _USERNAME_PLACEHOLDER
|
||||
@@ -162,3 +162,21 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
||||
return {"cost": total_cost, "revenue": total_revenue}
|
||||
|
||||
|
||||
async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
|
||||
"""Delete ad campaign by id along with related attributions.
|
||||
|
||||
Returns True if campaign existed and was deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
campaign = await session.get(AdCampaign, campaign_id)
|
||||
if not campaign:
|
||||
return False
|
||||
await session.delete(campaign)
|
||||
await session.flush()
|
||||
logging.info(f"AdCampaign deleted id={campaign_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id(
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc))
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
if panel_user_uuid:
|
||||
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||
stmt = stmt.order_by(Subscription.end_date.desc())
|
||||
stmt = stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_subscription_by_panel_subscription_uuid(
|
||||
|
||||
+8
-1
@@ -228,6 +228,7 @@
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
"admin_user_recent_actions_title": "📜 Recent actions of user {user_id}:",
|
||||
"admin_user_not_found": "❌ User not found: {input}",
|
||||
"admin_user_not_found_action": "User not found",
|
||||
"admin_user_card_error": "❌ Error displaying user card",
|
||||
@@ -316,6 +317,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
"admin_user_subscription_error": "Loading error",
|
||||
"admin_promo_management_button": "🎟 Promo Management",
|
||||
@@ -428,5 +431,9 @@
|
||||
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
|
||||
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ Back to list",
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>"
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Delete campaign",
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found."
|
||||
}
|
||||
|
||||
+8
-1
@@ -227,6 +227,7 @@
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
"admin_user_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
|
||||
"admin_user_not_found": "❌ Пользователь не найден: {input}",
|
||||
"admin_user_not_found_action": "Пользователь не найден",
|
||||
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
|
||||
@@ -315,6 +316,8 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
"admin_user_subscription_error": "Ошибка загрузки",
|
||||
"admin_promo_management_button": "🎟 Управление промокодами",
|
||||
@@ -427,5 +430,9 @@
|
||||
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
|
||||
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ К списку",
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>"
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user