Compare commits

..
11 Commits
Author SHA1 Message Date
machka paslaandGitHub 1d57c9be21 Merge pull request #96 from machka-pasla/dev
name filtration
2025-10-09 21:11:17 +03:00
machka pasla fde0f1b135 username filtration 2025-10-09 21:08:01 +03:00
Machka PaslaandGitHub 67df164498 Merge pull request #94 from machka-pasla/dev
tribute grace fix
2025-09-28 19:24:45 +03:00
machka-pasla 48e666f605 grace fix 2025-09-28 23:24:16 +07:00
machka-pasla 3cb15fa254 Add recent actions title to user management translations in English and Russian locales. 2025-09-16 15:19:36 +03:00
machka-pasla 2100949470 Add referral statistics to user card formatting. Updated user management to include referral service and added new translations for invited friends and purchased subscriptions in English and Russian locales. 2025-09-16 15:14:22 +03:00
Machka PaslaandGitHub c2a475b062 Merge pull request #90 from machka-pasla/dev
Bugfixes
2025-09-12 13:15:04 +03:00
machka-pasla 585cbe8fb6 Refactor subscription retrieval logic to limit results to the most recent active subscription. Updated query execution to return the first scalar result for improved performance. 2025-09-12 12:57:35 +03:00
machka-pasla a243ddcac8 Refactor ads deletion callback data parsing to improve clarity and maintainability. Updated the way campaign ID and back page are extracted from the callback data. 2025-09-11 23:05:57 +03:00
machka-pasla 357d7caf26 Refactor i18n handling in ads deletion prompt to use direct gettext calls. This improves language support and error messaging consistency. 2025-09-11 23:03:41 +03:00
machka-pasla 2713b3bae5 Add ad campaign deletion functionality with confirmation prompts and error handling. Updated inline keyboards and translations for delete actions. Enhanced subscription command handler to include fallback for config link URL. 2025-09-11 22:58:12 +03:00
16 changed files with 569 additions and 72 deletions
+120
View File
@@ -127,6 +127,126 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d
await callback.answer() 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") @router.callback_query(F.data == "admin_action:ads_create")
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict): async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
from bot.states.admin_states import AdminStates from bot.states.admin_states import AdminStates
+40 -8
View File
@@ -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.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils import get_message_content, send_direct_message from bot.utils import get_message_content, send_direct_message
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton 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") router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") 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, async def format_user_card(user: User, session: AsyncSession,
subscription_service: SubscriptionService, 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""" """Format user information as a detailed card"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
@@ -116,8 +123,16 @@ async def format_user_card(user: User, session: AsyncSession,
# User details # User details
na_value = _("admin_user_na_value", default="N/A") na_value = _("admin_user_na_value", default="N/A")
user_name = user.first_name or na_value safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
username_display = f"@{user.username}" if user.username else na_value 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 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))}") card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
@@ -176,6 +191,17 @@ async def format_user_card(user: User, session: AsyncSession,
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id) had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал") 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)}") 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: except Exception as e:
logging.error(f"Error getting user statistics for {user.user_id}: {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 # Format and send user card
try: 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) keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
await message.answer( 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) await callback.answer("User not found", show_alert=True)
return 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) keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
try: try:
@@ -556,7 +586,8 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
# Show updated user card # Show updated user card
user = await user_dal.get_user_by_id(session, target_user_id) user = await user_dal.get_user_by_id(session, target_user_id)
if user: 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) keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
await message.answer( 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 from bot.services.panel_api_service import PanelApiService
async with PanelApiService(settings) as panel_service: async with PanelApiService(settings) as panel_service:
subscription_service = SubscriptionService(settings, 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) keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
await message.answer( await message.answer(
@@ -931,4 +963,4 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
default="❌ Ошибка разблокировки пользователя" default="❌ Ошибка разблокировки пользователя"
)) ))
await state.clear() await state.clear()
+7 -4
View File
@@ -22,6 +22,7 @@ from bot.middlewares.i18n import JsonI18n
from config.settings import Settings from config.settings import Settings
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard 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() 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: if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id( inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id) session, db_user.referred_by_id)
if inviter and inviter.first_name: if inviter:
inviter_name_display = inviter.first_name safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
elif inviter and inviter.username: if safe_name:
inviter_name_display = f"@{inviter.username}" inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
details_message = _( details_message = _(
"payment_successful_with_referral_bonus_full", "payment_successful_with_referral_bonus_full",
+16 -11
View File
@@ -17,6 +17,7 @@ from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings from config.settings import Settings
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
router = Router(name="user_start_router") 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) ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}") 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) db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
"username": user.username, "username": sanitized_username,
"first_name": user.first_name, "first_name": sanitized_first_name,
"last_name": user.last_name, "last_name": sanitized_last_name,
"language_code": current_lang, "language_code": current_lang,
"referred_by_id": referred_by_user_id, "referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc) "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) notification_service = NotificationService(message.bot, settings, i18n)
await notification_service.notify_new_user_registration( await notification_service.notify_new_user_registration(
user_id=user_id, user_id=user_id,
username=user.username, username=sanitized_username,
first_name=user.first_name, first_name=sanitized_first_name,
referred_by_id=referred_by_user_id referred_by_id=referred_by_user_id
) )
except Exception as e: except Exception as e:
@@ -203,12 +208,12 @@ async def start_command_handler(message: types.Message,
is_active_now = False is_active_now = False
if not is_active_now: if not is_active_now:
update_payload["referred_by_id"] = referred_by_user_id update_payload["referred_by_id"] = referred_by_user_id
if user.username != db_user.username: if sanitized_username != db_user.username:
update_payload["username"] = user.username update_payload["username"] = sanitized_username
if user.first_name != db_user.first_name: if sanitized_first_name != db_user.first_name:
update_payload["first_name"] = user.first_name update_payload["first_name"] = sanitized_first_name
if user.last_name != db_user.last_name: if sanitized_last_name != db_user.last_name:
update_payload["last_name"] = user.last_name update_payload["last_name"] = sanitized_last_name
if update_payload: if update_payload:
try: try:
+10 -1
View File
@@ -155,7 +155,7 @@ async def my_subscription_command_handler(
# Build rows to prepend above the base "back" markup # Build rows to prepend above the base "back" markup
prepend_rows = [] 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: if settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([ prepend_rows.append([
InlineKeyboardButton( InlineKeyboardButton(
@@ -163,6 +163,15 @@ async def my_subscription_command_handler(
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), 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) # 2) Auto-renew toggle (if supported and not tribute)
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
+3
View File
@@ -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: 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) _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder() 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="⬅️ К списку"), builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
callback_data=f"admin_ads:page:{back_page}") callback_data=f"admin_ads:page:{back_page}")
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
+15 -11
View File
@@ -6,6 +6,7 @@ from aiogram.types import Update, User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import user_dal from db.dal import user_dal
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
class ProfileSyncMiddleware(BaseMiddleware): class ProfileSyncMiddleware(BaseMiddleware):
@@ -24,12 +25,16 @@ class ProfileSyncMiddleware(BaseMiddleware):
db_user = await user_dal.get_user_by_id(session, tg_user.id) db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user: if db_user:
update_payload: Dict[str, Any] = {} update_payload: Dict[str, Any] = {}
if db_user.username != tg_user.username: sanitized_username = sanitize_username(tg_user.username)
update_payload["username"] = tg_user.username sanitized_first_name = sanitize_display_name(tg_user.first_name)
if db_user.first_name != tg_user.first_name: sanitized_last_name = sanitize_display_name(tg_user.last_name)
update_payload["first_name"] = tg_user.first_name
if db_user.last_name != tg_user.last_name: if db_user.username != sanitized_username:
update_payload["last_name"] = tg_user.last_name 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: if update_payload:
await user_dal.update_user(session, tg_user.id, 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") panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid: if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([ description_text = "\n".join([
tg_user.username or "", username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
tg_user.first_name or "", sanitized_first_name or "",
tg_user.last_name or "", sanitized_last_name or "",
]) ]).strip()
await panel_service.update_user_details_on_panel( await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid, db_user.panel_user_uuid,
{"description": description_text}, {"description": description_text},
@@ -62,4 +67,3 @@ class ProfileSyncMiddleware(BaseMiddleware):
return await handler(event, data) return await handler(event, data)
+7 -4
View File
@@ -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.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
class CryptoPayService: class CryptoPayService:
@@ -189,10 +190,12 @@ class CryptoPayService:
inviter_name_display = _("friend_placeholder") inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id: if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, 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: if inviter:
inviter_name_display = inviter.first_name safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
elif inviter and inviter.username: if safe_name:
inviter_name_display = f"@{inviter.username}" 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", text = _("payment_successful_with_referral_bonus_full",
months=months, months=months,
base_end_date=activation["end_date"].strftime('%Y-%m-%d'), base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
+38 -16
View File
@@ -10,6 +10,10 @@ from config.settings import Settings
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager from bot.utils.message_queue import get_queue_manager
from bot.utils.text_sanitizer import (
display_name_or_fallback,
username_for_display,
)
class NotificationService: class NotificationService:
@@ -19,6 +23,17 @@ class NotificationService:
self.bot = bot self.bot = bot
self.settings = settings self.settings = settings
self.i18n = i18n 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): async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
"""Send message to configured log channel/group using message queue""" """Send message to configured log channel/group using message queue"""
@@ -101,9 +116,11 @@ class NotificationService:
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = first_name or f"ID {user_id}" user_display = self._format_user_display(
if username: user_id=user_id,
user_display += f" (@{username})" username=username,
first_name=first_name,
)
referral_text = "" referral_text = ""
if referred_by_id: if referred_by_id:
@@ -134,9 +151,10 @@ class NotificationService:
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}" user_display = self._format_user_display(
if username: user_id=user_id,
user_display += f" (@{username})" username=username,
)
provider_emoji = { provider_emoji = {
"yookassa": "💳", "yookassa": "💳",
@@ -174,9 +192,10 @@ class NotificationService:
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}" user_display = self._format_user_display(
if username: user_id=user_id,
user_display += f" (@{username})" username=username,
)
message = _( message = _(
"log_promo_activation", "log_promo_activation",
@@ -203,9 +222,10 @@ class NotificationService:
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}" user_display = self._format_user_display(
if username: user_id=user_id,
user_display += f" (@{username})" username=username,
)
message = _( message = _(
"log_trial_activation", "log_trial_activation",
@@ -268,9 +288,11 @@ class NotificationService:
_ = lambda k, **kw: self.i18n.gettext( _ = lambda k, **kw: self.i18n.gettext(
admin_lang, k, **kw) if self.i18n else k admin_lang, k, **kw) if self.i18n else k
user_display = first_name or f"ID {user_id}" user_display = self._format_user_display(
if username: user_id=user_id,
user_display += f" (@{username})" username=username,
first_name=first_name,
)
message = _( message = _(
"log_suspicious_promo", "log_suspicious_promo",
@@ -295,4 +317,4 @@ class NotificationService:
if to_admins: if to_admins:
await self._send_to_admins(message) await self._send_to_admins(message)
# Removed legacy helper functions that duplicated NotificationService API # Removed legacy helper functions that duplicated NotificationService API
+7 -5
View File
@@ -12,6 +12,7 @@ from .referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from .notification_service import NotificationService from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard 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: class StarsService:
@@ -124,10 +125,12 @@ class StarsService:
db_user = await user_dal.get_user_by_id(session, message.from_user.id) db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if db_user and db_user.referred_by_id: if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, 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: if inviter:
inviter_name_display = inviter.first_name safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
elif inviter and inviter.username: if safe_name:
inviter_name_display = f"@{inviter.username}" inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _( success_msg = _(
"payment_successful_with_referral_bonus_full", "payment_successful_with_referral_bonus_full",
months=months, months=months,
@@ -173,4 +176,3 @@ class StarsService:
) )
except Exception as e: except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}") logging.error(f"Failed to send stars payment notification: {e}")
+40 -6
View File
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
from .notification_service import NotificationService from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal 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: def convert_period_to_months(period: Optional[str]) -> int:
@@ -184,10 +185,12 @@ class TributeService:
inviter_name_display = _('friend_placeholder') inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id: if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, 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: if inviter:
inviter_name_display = inviter.first_name safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
elif inviter and inviter.username: if safe_name:
inviter_name_display = f"@{inviter.username}" inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _( success_msg = _(
"payment_successful_with_referral_bonus_full", "payment_successful_with_referral_bonus_full",
months=months, months=months,
@@ -250,8 +253,39 @@ class TributeService:
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
try: try:
# Set all user's subscriptions to expire in 1 day (grace period) grace_days = 1
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, 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() await session.commit()
# Send notification about cancellation if enabled # Send notification about cancellation if enabled
+226
View File
@@ -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
+18
View File
@@ -162,3 +162,21 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
return {"cost": total_cost, "revenue": total_revenue} 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
+6 -4
View File
@@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id(
user_id: int, user_id: int,
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]: panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
stmt = select(Subscription).where( stmt = select(Subscription).where(
Subscription.user_id == user_id, Subscription.is_active == True, Subscription.user_id == user_id,
Subscription.end_date > datetime.now(timezone.utc)) Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
if panel_user_uuid: if panel_user_uuid:
stmt = stmt.where(Subscription.panel_user_uuid == 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) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalars().first()
async def get_subscription_by_panel_subscription_uuid( async def get_subscription_by_panel_subscription_uuid(
+8 -1
View File
@@ -228,6 +228,7 @@
"admin_user_search_new_button": "🔍 Find Another", "admin_user_search_new_button": "🔍 Find Another",
"admin_user_view_all_logs_button": "📋 All Actions", "admin_user_view_all_logs_button": "📋 All Actions",
"admin_user_back_to_card_button": "🔙 Back to Card", "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": "❌ User not found: {input}",
"admin_user_not_found_action": "User not found", "admin_user_not_found_action": "User not found",
"admin_user_card_error": "❌ Error displaying user card", "admin_user_card_error": "❌ Error displaying user card",
@@ -316,6 +317,8 @@
"admin_user_na_value": "N/A", "admin_user_na_value": "N/A",
"admin_user_subscription_none": "No active subscription", "admin_user_subscription_none": "No active subscription",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>", "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_active_until": "⏰ <b>Active until:</b>",
"admin_user_subscription_error": "Loading error", "admin_user_subscription_error": "Loading error",
"admin_promo_management_button": "🎟 Promo Management", "admin_promo_management_button": "🎟 Promo Management",
@@ -428,5 +431,9 @@
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:", "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>", "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", "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
View File
@@ -227,6 +227,7 @@
"admin_user_search_new_button": "🔍 Новый поиск", "admin_user_search_new_button": "🔍 Новый поиск",
"admin_user_view_all_logs_button": "📋 Все логи", "admin_user_view_all_logs_button": "📋 Все логи",
"admin_user_back_to_card_button": "🔙 К карточке", "admin_user_back_to_card_button": "🔙 К карточке",
"admin_user_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
"admin_user_not_found": "❌ Пользователь не найден: {input}", "admin_user_not_found": "❌ Пользователь не найден: {input}",
"admin_user_not_found_action": "Пользователь не найден", "admin_user_not_found_action": "Пользователь не найден",
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя", "admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
@@ -315,6 +316,8 @@
"admin_user_na_value": "N/A", "admin_user_na_value": "N/A",
"admin_user_subscription_none": "Нет активной подписки", "admin_user_subscription_none": "Нет активной подписки",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>", "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_active_until": "⏰ <b>Действует до:</b>",
"admin_user_subscription_error": "Ошибка загрузки", "admin_user_subscription_error": "Ошибка загрузки",
"admin_promo_management_button": "🎟 Управление промокодами", "admin_promo_management_button": "🎟 Управление промокодами",
@@ -427,5 +430,9 @@
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:", "admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>", "admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
"back_to_ads_list_button": "⬅️ К списку", "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": "Кампания не найдена."
} }