Compare commits

...
13 Commits
Author SHA1 Message Date
machka paslaandGitHub a3406451cb Merge pull request #98 from machka-pasla/dev
auto renew message with trial fix
2025-10-10 20:05:26 +03:00
machka pasla 9938671af9 auto renew message fix 2025-10-10 20:04:50 +03:00
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
17 changed files with 570 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()
@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
+39 -7
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.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(
+7 -4
View File
@@ -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
View File
@@ -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:
+10 -1
View File
@@ -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):
+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:
_ = 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"),
+15 -11
View File
@@ -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)
+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.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'),
+37 -15
View File
@@ -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",
+7 -5
View File
@@ -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}")
+1
View File
@@ -355,6 +355,7 @@ class SubscriptionService:
"is_active": True,
"status_from_panel": "TRIAL",
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
"auto_renew_enabled": False,
}
try:
await subscription_dal.upsert_subscription(session, trial_sub_data)
+40 -6
View File
@@ -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
+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}
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,
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
View File
@@ -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
View File
@@ -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": "Кампания не найдена."
}