refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+18
View File
@@ -0,0 +1,18 @@
from aiogram import Router
from . import ads, broadcast, common, logs_admin, payments, statistics, sync_admin, user_management
from .promo import promo_router_aggregate
admin_router_aggregate = Router(name="admin_features_router")
admin_router_aggregate.include_router(common.router)
admin_router_aggregate.include_router(broadcast.router)
admin_router_aggregate.include_router(promo_router_aggregate)
admin_router_aggregate.include_router(user_management.router)
admin_router_aggregate.include_router(statistics.router)
admin_router_aggregate.include_router(sync_admin.router)
admin_router_aggregate.include_router(logs_admin.router)
admin_router_aggregate.include_router(payments.router)
admin_router_aggregate.include_router(ads.router)
__all__ = ("admin_router_aggregate",)
+398
View File
@@ -0,0 +1,398 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import ad_dal
router = Router(name="admin_ads_router")
PAGE_SIZE = 5
@router.callback_query(F.data == "admin_action:ads")
async def show_ads_menu(
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
totals = await ad_dal.get_totals(session)
total_cost = totals.get("cost", 0.0)
total_revenue = totals.get("revenue", 0.0)
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
total_count = await ad_dal.count_campaigns(session)
if total_count == 0:
text = overview + "\n\n" + _("admin_ads_empty")
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
else:
current_page = 0
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
campaigns = await ad_dal.list_campaigns_paged(
session, page=current_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, current_page, total_pages
)
await callback.message.edit_text(text, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("admin_ads:page:"))
async def ads_list_pagination(
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:
page = int(callback.data.split(":")[2])
except Exception:
page = 0
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(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()
except Exception as e:
logging.error(f"Failed to paginate ads list: {e}")
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:card:"))
async def show_ad_card(
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
parts = callback.data.split(":")
camp_id = int(parts[2])
back_page = int(parts[3]) if len(parts) > 3 else 0
camp = await ad_dal.get_campaign_by_id(session, camp_id)
if not camp:
await callback.answer(_("admin_promo_not_found"), 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 as e:
logging.error(f"Failed to show ad card: {e}")
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"), 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"), 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
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
await state.set_state(AdminStates.waiting_for_ad_source)
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
try:
await callback.answer()
except Exception:
pass
@router.message(
StateFilter(
AdminStates.waiting_for_ad_source,
AdminStates.waiting_for_ad_start_param,
AdminStates.waiting_for_ad_cost,
),
F.text,
)
async def ads_create_flow(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_state = await state.get_state()
if current_state not in (
AdminStates.waiting_for_ad_source.state,
AdminStates.waiting_for_ad_start_param.state,
AdminStates.waiting_for_ad_cost.state,
):
return
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 current_state == AdminStates.waiting_for_ad_source.state:
source = message.text.strip()
if not source or len(source) > 64:
await message.answer(_("admin_ads_invalid_source"))
return
await state.update_data(ad_source=source)
await state.set_state(AdminStates.waiting_for_ad_start_param)
await message.answer(_("admin_ads_create_start_param_prompt"))
return
if current_state == AdminStates.waiting_for_ad_start_param.state:
start_param = message.text.strip()
# Allow alnum underscore dash only
import re as _re
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
await message.answer(_("admin_ads_invalid_start_param"))
return
await state.update_data(ad_start_param=start_param)
await state.set_state(AdminStates.waiting_for_ad_cost)
await message.answer(_("admin_ads_create_cost_prompt"))
return
if current_state == AdminStates.waiting_for_ad_cost.state:
text = message.text.replace(",", ".").strip()
try:
cost = float(text)
if cost < 0 or cost > 1e8:
raise ValueError()
except Exception:
await message.answer(_("admin_ads_invalid_cost"))
return
data = await state.get_data()
try:
campaign = await ad_dal.create_campaign(
session,
source=data.get("ad_source", "unknown"),
start_param=data.get("ad_start_param", "NA"),
cost=cost,
)
await session.commit()
except ValueError as ve:
await session.rollback()
if str(ve) == "ad_campaign_start_param_exists":
await message.answer(_("admin_ads_start_param_exists"))
else:
await message.answer(_("error_occurred_try_again"))
return
except Exception as e:
await session.rollback()
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
await message.answer(_("error_occurred_try_again"))
return
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await message.answer(
_(
"admin_ads_created_success",
id=campaign.ad_campaign_id,
source=campaign.source,
start_param=campaign.start_param,
cost=f"{campaign.cost:.2f}",
)
)
# Offer back to ads menu
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
await message.answer(
_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang)
)
+402
View File
@@ -0,0 +1,402 @@
import asyncio
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
get_broadcast_confirmation_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from bot.utils import (
MessageContent,
get_message_content,
send_message_by_type,
send_message_via_queue,
)
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from db.dal import message_log_dal, user_dal
router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in broadcast_message_prompt_handler")
await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _("admin_broadcast_enter_message")
if callback.message:
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
except Exception as e:
logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message)
async def process_broadcast_message_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in process_broadcast_message_handler")
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Определяем тип содержимого и сохраняем данные в state
entities = message.entities or message.caption_entities or []
content = get_message_content(message)
# Если нет ни текста, ни медиа — ошибка
if not content.text and not content.file_id:
await message.answer(_("admin_broadcast_error_no_message"))
return
# Сохраняем данные для рассылки
await state.update_data(
broadcast_text=content.text,
broadcast_entities=entities,
broadcast_content_type=content.content_type,
broadcast_file_id=content.file_id,
broadcast_target="all",
)
# Отправляем превью-копию того, что будет разослано
try:
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
else:
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
except TelegramBadRequest as e:
await message.answer(
_(
"admin_broadcast_invalid_html",
error=str(e),
)
)
return
# Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
await message.answer(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"),
)
await state.set_state(AdminStates.confirming_broadcast)
@router.callback_query(
F.data.startswith("broadcast_target:"),
AdminStates.confirming_broadcast,
)
async def change_broadcast_target_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
):
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("Error updating selection.", show_alert=True)
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
await callback.answer("Unknown target.", show_alert=True)
return
await state.update_data(broadcast_target=new_target)
await state.get_data()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
try:
await callback.message.edit_text(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target),
)
except Exception:
pass
await callback.answer()
@router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear()
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(
F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast,
)
async def confirm_broadcast_callback_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
bot: Bot,
settings: Settings,
session: AsyncSession,
):
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("Error processing broadcast confirmation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1]
user_fsm_data = await state.get_data()
if action == "send":
# Создаем объект контента из сохраненных данных
content = MessageContent(
content_type=user_fsm_data.get("broadcast_content_type", "text"),
file_id=user_fsm_data.get("broadcast_file_id"),
text=user_fsm_data.get("broadcast_text"),
)
entities = user_fsm_data.get("broadcast_entities", [])
if not content.text and content.content_type == "text":
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear()
await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
await callback.answer()
target = user_fsm_data.get("broadcast_target", "all")
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent_count = 0
failed_count = 0
admin_user = callback.from_user
logging.info(
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501
)
# Get message queue manager
queue_manager = get_queue_manager()
if not queue_manager:
await callback.message.edit_text(
"❌ Ошибка: система очередей не инициализирована", reply_markup=None
)
return
# Queue all messages for sending
for uid in user_ids:
try:
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
)
else:
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
)
sent_count += 1
# Log successful queuing
await message_log_dal.create_message_log(
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_queued",
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501
"is_admin_event": True,
"target_user_id": uid,
},
)
except Exception as e:
failed_count += 1
logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}")
await message_log_dal.create_message_log(
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content": f"For user {uid}: {type(e).__name__} {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
try:
await session.commit()
except Exception as e_commit:
await session.rollback()
logging.error(f"Error committing broadcast logs: {e_commit}")
# Prepare queue stats presentation
queue_stats = queue_manager.get_queue_stats()
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
initial_user_failed = queue_stats.get("user_failed_messages", 0)
initial_group_failed = queue_stats.get("group_failed_messages", 0)
def build_queue_status(stats: dict) -> str:
dynamic_failed = max(
0, stats.get("user_failed_messages", 0) - initial_user_failed
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
total_failed = failed_count + dynamic_failed
return _(
"broadcast_queue_result",
sent_count=sent_count,
failed_count=total_failed,
user_queue_size=stats["user_queue_size"],
group_queue_size=stats["group_queue_size"],
)
result_message = build_queue_status(queue_stats)
status_message = await callback.message.answer(
result_message,
reply_markup=back_keyboard,
)
async def auto_update_queue_status() -> None:
"""Refresh queue stats message twice per second via message edit."""
last_text = result_message
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
max_iterations = 240
for _ in range(max_iterations):
await asyncio.sleep(0.5)
stats = queue_manager.get_queue_stats()
new_text = build_queue_status(stats)
queues_drained = (
stats["user_queue_size"] == 0
and stats["group_queue_size"] == 0
and not stats.get("user_queue_processing")
and not stats.get("group_queue_processing")
)
if new_text != last_text:
try:
await status_message.edit_text(
new_text,
reply_markup=back_keyboard,
)
last_text = new_text
except TelegramBadRequest as e:
if "message is not modified" in str(e):
last_text = new_text
else:
logging.debug("Broadcast queue auto-update stopped: %s", e)
break
except Exception as e:
logging.debug("Broadcast queue auto-update unexpected error: %s", e)
break
if queues_drained:
# Final refresh already attempted; exit loop.
break
else:
logging.debug("Broadcast queue auto-update reached time limit.")
asyncio.create_task(auto_update_queue_status())
elif action == "cancel":
await callback.message.edit_text(
_("admin_broadcast_cancelled"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.clear()
+299
View File
@@ -0,0 +1,299 @@
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_ban_management_keyboard,
get_promo_marketing_keyboard,
get_stats_monitoring_keyboard,
get_system_functions_keyboard,
get_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from . import broadcast as admin_broadcast_handlers
from . import logs_admin as admin_logs_handlers
from . import statistics as admin_stats_handlers
from . import sync_admin as admin_sync_handlers
from . import user_management as admin_user_mgmnt_handlers
from .promo import bulk as admin_promo_bulk_handlers
from .promo import create as admin_promo_create_handlers
from .promo import manage as admin_promo_manage_handlers
router = Router(name="admin_common_router")
@router.message(Command("admin"))
async def admin_panel_command_handler(
message: types.Message,
state: FSMContext,
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:
logging.error("i18n missing in admin_panel_command_handler")
await message.answer("Language service error.")
return
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(F.data.startswith("admin_action:"))
async def admin_panel_actions_callback_handler(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
bot: Bot,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
):
action_parts = callback.data.split(":")
action = action_parts[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in admin_panel_actions_callback_handler")
await callback.answer("Language error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error(
f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" # noqa: E501
)
await callback.answer("Error processing action: message context lost.", show_alert=True)
return
if action == "stats":
await admin_stats_handlers.show_statistics_handler(callback, i18n_data, settings, session)
elif action == "broadcast":
await admin_broadcast_handlers.broadcast_message_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "create_promo":
await admin_promo_create_handlers.create_promo_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "create_bulk_promo":
await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "manage_promos":
await admin_promo_manage_handlers.manage_promo_codes_handler(
callback, i18n_data, settings, session
)
elif action == "view_promos":
await admin_promo_manage_handlers.view_promo_codes_handler(
callback, i18n_data, settings, session
)
elif action == "ban_user_prompt":
await admin_user_mgmnt_handlers.ban_user_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "unban_user_prompt":
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "users_management":
# This is deprecated, kept for compatibility
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "users_list" and len(action_parts) > 2:
# Route to users list handler with page number
from . import user_management as admin_user_management_handlers
try:
page = int(action_parts[2])
await admin_user_management_handlers.users_list_handler(
callback, i18n_data, settings, session, page
)
except (IndexError, ValueError):
await callback.answer("Invalid page number", show_alert=True)
elif action == "users_search_prompt":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session
)
elif action == "view_banned":
await admin_user_mgmnt_handlers.view_banned_users_handler(
callback, state, i18n_data, settings, session
)
elif action == "view_logs_menu":
await admin_logs_handlers.display_logs_menu(callback, i18n_data, settings, session)
elif action == "promo_management":
await admin_promo_manage_handlers.promo_management_handler(
callback, i18n_data, settings, session
)
elif action == "sync_panel":
await admin_sync_handlers.sync_command_handler(
message_event=callback,
bot=bot,
settings=settings,
i18n_data=i18n_data,
panel_service=panel_service,
session=session,
)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler(callback, i18n_data, settings, session)
elif action == "user_ratings":
await admin_stats_handlers.show_user_ratings_handler(callback, i18n_data, settings, session)
elif action == "ads":
from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
elif action == "ads_create":
from . import ads as admin_ads_handlers
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
elif action == "main":
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer()
else:
logging.warning(f"Unknown admin_action received: {action} from callback {callback.data}")
await callback.answer(_("admin_unknown_action"), show_alert=True)
@router.callback_query(F.data.startswith("admin_section:"))
async def admin_section_handler(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
section = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
await callback.answer("Error: message context lost.", show_alert=True)
return
try:
if section == "stats_monitoring":
await callback.message.edit_text(
_("admin_stats_and_monitoring_section"),
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang),
)
elif section == "user_management":
await callback.message.edit_text(
_("admin_user_management_section"),
reply_markup=get_user_management_keyboard(i18n, current_lang),
)
elif section == "ban_management":
await callback.message.edit_text(
_("admin_ban_management_section"),
reply_markup=get_ban_management_keyboard(i18n, current_lang),
)
elif section == "promo_marketing":
await callback.message.edit_text(
_("admin_promo_marketing_section"),
reply_markup=get_promo_marketing_keyboard(i18n, current_lang),
)
elif section == "system_functions":
await callback.message.edit_text(
_("admin_system_functions_section"),
reply_markup=get_system_functions_keyboard(i18n, current_lang),
)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
return
await callback.answer()
except Exception as e:
logging.error(f"Error handling admin section {section}: {e}")
await callback.message.answer(
_("error_occurred_try_again"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer()
async def show_queue_status_handler(callback: types.CallbackQuery, i18n_data: dict):
"""Show message queue status to admin"""
current_lang = i18n_data.get("current_language", "ru")
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
queue_manager = get_queue_manager()
if not queue_manager:
from aiogram.utils.keyboard import InlineKeyboardBuilder
await callback.message.edit_text(
"❌ Система очередей не инициализирована",
reply_markup=InlineKeyboardBuilder()
.button(text=_("back_to_admin_panel_button"), callback_data="admin_action:main")
.as_markup(),
)
await callback.answer()
return
try:
stats = queue_manager.get_queue_stats()
message_text = _(
"admin_queue_status_info",
user_queue_size=stats["user_queue_size"],
user_processing="✅ Да" if stats["user_queue_processing"] else "❌ Нет",
user_recent=stats["user_recent_sends"],
group_queue_size=stats["group_queue_size"],
group_processing="✅ Да" if stats["group_queue_processing"] else "❌ Нет",
group_recent=stats["group_recent_sends"],
)
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
except Exception as e:
logging.error(f"Error getting queue status: {e}")
await callback.answer("❌ Ошибка получения статуса очередей", show_alert=True)
+453
View File
@@ -0,0 +1,453 @@
import csv
import io
import logging
import math
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_logs_menu_keyboard,
get_logs_pagination_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import message_log_dal, user_dal
from db.models import MessageLog, User
router = Router(name="admin_logs_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
async def display_logs_menu(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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("Error displaying logs menu.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
except Exception as e:
logging.warning(f"Failed to edit message for logs menu: {e}. Sending new.")
await callback.message.answer(
text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
await callback.answer()
async def _display_formatted_logs(
target_message: types.Message,
logs: List[MessageLog],
total_logs: int,
current_page_idx: int,
settings: Settings,
title_key: str,
base_pagination_callback_data: str,
i18n: JsonI18n,
current_lang: str,
title_kwargs: Optional[Dict[str, Any]] = None,
):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = settings.LOGS_PAGE_SIZE
actual_title_kwargs = title_kwargs or {}
if not logs and total_logs == 0:
text = (
_(title_key, current_page=1, total_pages=1, **actual_title_kwargs)
+ "\n\n"
+ _("admin_no_logs_found")
)
reply_markup = get_logs_pagination_keyboard(
current_page_idx,
1,
base_pagination_callback_data,
i18n,
current_lang,
back_to_logs_menu=True,
)
else:
total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1
text = (
_(
title_key,
current_page=current_page_idx + 1,
total_pages=max(1, total_pages),
**actual_title_kwargs,
)
+ "\n"
)
log_entries_text = []
for log_entry_model in logs:
user_display_parts = []
if log_entry_model.telegram_first_name:
user_display_parts.append(log_entry_model.telegram_first_name)
if log_entry_model.telegram_username:
user_display_parts.append(f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = (
_("system_or_unknown_user")
if not log_entry_model.user_id
else f"ID: {log_entry_model.user_id}"
)
user_id_display = (
str(log_entry_model.user_id) if log_entry_model.user_id is not None else "N/A"
)
content_raw = log_entry_model.content or ""
content_preview = (
(content_raw[:100] + "...") if len(content_raw) > 100 else (content_raw or "N/A")
)
timestamp_str_display = (
log_entry_model.timestamp.strftime("%Y-%m-%d %H:%M:%S")
if log_entry_model.timestamp
else "N/A"
)
log_entries_text.append(
_(
"admin_log_entry_format",
timestamp_str=timestamp_str_display,
user_display=user_display,
user_id=user_id_display,
event_type=log_entry_model.event_type or "N/A",
content_preview=content_preview,
).replace("\n", "\n ")
)
text += "\n\n".join(log_entries_text)
reply_markup = get_logs_pagination_keyboard(
current_page_idx,
total_pages,
base_pagination_callback_data,
i18n,
current_lang,
back_to_logs_menu=True,
)
try:
await target_message.edit_text(
text, reply_markup=reply_markup, parse_mode="HTML", disable_web_page_preview=True
)
except Exception as e:
logging.warning(
f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." # noqa: E501
)
max_chunk_size = 4000
for i in range(0, len(text), max_chunk_size):
chunk = text[i : i + max_chunk_size]
is_last_chunk = (i + max_chunk_size) >= len(text)
try:
await target_message.answer(
chunk,
reply_markup=reply_markup if is_last_chunk else None,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_chunk:
logging.error(f"Failed to send log chunk: {e_chunk}")
if i == 0:
await target_message.answer(
_("error_displaying_logs_too_long"),
reply_markup=reply_markup if is_last_chunk else None,
)
break
@router.callback_query(F.data.startswith("admin_logs:view_all"))
async def view_all_logs_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
page_idx = 0
parts = callback.data.split(":")
if len(parts) == 3:
try:
page_idx = int(parts[2])
except ValueError:
page_idx = 0
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
logs_models = await message_log_dal.get_all_message_logs(
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE
)
total_logs_count = await message_log_dal.count_all_message_logs(session)
await _display_formatted_logs(
target_message=callback.message,
logs=logs_models,
total_logs=total_logs_count,
current_page_idx=page_idx,
settings=settings,
title_key="admin_all_logs_title",
base_pagination_callback_data="admin_logs:view_all",
i18n=i18n,
current_lang=current_lang,
)
await callback.answer()
@router.callback_query(F.data == "admin_logs:prompt_user")
async def prompt_user_for_logs_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error preparing user log prompt.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.message.edit_text(
text=_("admin_prompt_for_user_id_or_username_logs"),
reply_markup=get_logs_menu_keyboard(i18n, current_lang),
)
await state.set_state(AdminStates.waiting_for_user_id_for_logs)
await callback.answer()
@router.message(AdminStates.waiting_for_user_id_for_logs, F.text)
async def process_user_id_for_logs_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
await state.clear()
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
input_text = message.text.strip() if message.text else ""
user_model_for_logs: Optional[User] = None
if input_text.isdigit() or (input_text.startswith("-") and input_text[1:].isdigit()):
try:
user_model_for_logs = await user_dal.get_user_by_id(session, int(input_text))
except ValueError:
pass
elif EMAIL_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_email(session, input_text)
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
user_model_for_logs = await user_dal.get_user_by_username(session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
user_model_for_logs = await user_dal.get_user_by_username(session, input_text)
if not user_model_for_logs:
await message.answer(_("admin_log_user_not_found", input=input_text))
return
target_user_id = user_model_for_logs.user_id
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
)
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, 0
)
total_user_logs_count = await message_log_dal.count_user_message_logs(session, target_user_id)
await _display_formatted_logs(
target_message=message,
logs=logs_models,
total_logs=total_user_logs_count,
current_page_idx=0,
settings=settings,
title_key="admin_user_logs_title",
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
title_kwargs={"user_display": user_display_name},
)
@router.callback_query(F.data.startswith("admin_logs:view_user:"))
async def view_user_logs_paginated_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
try:
parts = callback.data.split(":")
target_user_id = int(parts[2])
page_idx = int(parts[3])
except (IndexError, ValueError):
await callback.answer("Invalid log request format.", show_alert=True)
return
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
user_model_for_logs = await user_dal.get_user_by_id(session, target_user_id)
if not user_model_for_logs:
await callback.message.edit_text("User not found for logs.")
await callback.answer()
return
user_display_name = user_model_for_logs.first_name or (
f"@{user_model_for_logs.username}"
if user_model_for_logs.username
else (user_model_for_logs.email or f"ID {target_user_id}")
)
logs_models = await message_log_dal.get_user_message_logs(
session, target_user_id, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE
)
total_user_logs_count = await message_log_dal.count_user_message_logs(session, target_user_id)
await _display_formatted_logs(
target_message=callback.message,
logs=logs_models,
total_logs=total_user_logs_count,
current_page_idx=page_idx,
settings=settings,
title_key="admin_user_logs_title",
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
title_kwargs={"user_display": user_display_name},
)
await callback.answer()
@router.callback_query(
F.data == "admin_action:view_logs_menu", AdminStates.waiting_for_user_id_for_logs
)
async def cancel_log_user_input_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
await state.clear()
await display_logs_menu(callback, i18n_data, settings, session)
@router.callback_query(F.data == "admin_logs:export_csv")
async def export_logs_csv_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error processing CSV export.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_("admin_logs_csv_export_started"))
try:
# Get all logs (limit to 10000 for performance)
logs_models = await message_log_dal.get_all_message_logs(session, limit=10000, offset=0)
if not logs_models:
await callback.message.answer(_("admin_logs_csv_no_data"))
return
# Create CSV content
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header
headers = [
_("admin_csv_header_log_id"),
_("admin_csv_header_timestamp"),
_("admin_csv_header_user_id"),
_("admin_csv_header_telegram_username"),
_("admin_csv_header_telegram_first_name"),
_("admin_csv_header_event_type"),
_("admin_csv_header_content"),
_("admin_csv_header_is_admin_event"),
_("admin_csv_header_target_user_id"),
_("admin_csv_header_raw_update_preview"),
]
csv_writer.writerow(headers)
# Write data rows
for log in logs_models:
# Format timestamp
timestamp_str = log.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") if log.timestamp else ""
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
content_clean = (log.content or "").replace("\n", " ").replace("\r", " ").strip()
raw_update_clean = (
(log.raw_update_preview or "").replace("\n", " ").replace("\r", " ").strip()
)
row = [
log.log_id or "",
timestamp_str,
log.user_id or "",
log.telegram_username or "",
log.telegram_first_name or "",
log.event_type or "",
content_clean,
"Yes" if log.is_admin_event else "No",
log.target_user_id or "",
raw_update_clean,
]
csv_writer.writerow(row)
# Create file
csv_content = csv_buffer.getvalue()
csv_buffer.close()
# Generate filename with current timestamp
now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document
csv_file = types.BufferedInputFile(
csv_content.encode("utf-8-sig"), # BOM for Excel compatibility
filename=filename,
)
await callback.message.answer_document(
csv_file,
caption=_(
"admin_logs_csv_export_success",
count=len(logs_models),
date=now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_("admin_logs_csv_export_failed", error=str(e)))
+302
View File
@@ -0,0 +1,302 @@
import csv
import io
import logging
from datetime import datetime
from typing import List, Optional
from aiogram import F, Router, types
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
router = Router(name="admin_payments_router")
async def get_payments_with_pagination(
session: AsyncSession, page: int = 0, page_size: int = 10
) -> tuple[List[Payment], int]:
"""Get payments with pagination and total count."""
offset = page * page_size
# Get total count
total_count = await payment_dal.get_payments_count(session)
# Get payments for current page
payments = await payment_dal.get_recent_payment_logs_with_user(
session, limit=page_size, offset=offset
)
return payments, total_count
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ("" if payment.status in pending_statuses else "")
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
provider_text = {
"yookassa": "YooKassa",
"telegram_stars": "Telegram Stars",
"cryptopay": "CryptoPay",
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
}.get(payment.provider, payment.provider or "Unknown")
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
if traffic_like:
traffic_val = payment.purchased_gb or payment.subscription_duration_months or 0
traffic_display = (
str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}"
)
period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display)
else:
period_line = _(
"admin_payment_months_label", months=payment.subscription_duration_months or 0
)
tariff_line = f"\nTariff: {payment.tariff_key}" if payment.tariff_key else ""
sale_line = f"\nSale mode: {payment.sale_mode}" if payment.sale_mode else ""
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
f"💳 {provider_text}\n"
f"📅 {payment_date}\n"
f"{period_line}{tariff_line}{sale_line}\n"
f"📋 {payment.status}\n"
f"📝 {payment.description or 'N/A'}"
)
async def view_payments_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
page: int = 0,
):
"""Display paginated list of all payments."""
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("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = 5 # Show 5 payments per page
payments, total_count = await get_payments_with_pagination(session, page, page_size)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
if not payments and page == 0:
await callback.message.edit_text(
_("admin_no_payments_found"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
return
# Format payments text
text_parts = [_("admin_payments_header")]
text_parts.append(
_(
"admin_payments_pagination_info",
shown=len(payments),
total=total_count,
current_page=page + 1,
total_pages=total_pages,
)
+ "\n"
)
for i, payment in enumerate(payments, 1):
text_parts.append(
f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang, settings)}" # noqa: E501
)
text_parts.append("") # Empty line between payments
# Build keyboard with pagination and export
builder = InlineKeyboardBuilder()
# Pagination buttons
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page - 1}")
)
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page + 1}")
)
if nav_buttons:
builder.row(*nav_buttons)
# Export and refresh buttons
builder.row(
InlineKeyboardButton(
text=_("admin_export_payments_csv"), callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments"), callback_data=f"payments_page:{page}"
),
)
# Back button
builder.row(
InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_section:stats_monitoring"
)
)
await callback.message.edit_text(
"\n".join(text_parts), reply_markup=builder.as_markup(), parse_mode="HTML"
)
await callback.answer()
@router.callback_query(F.data.startswith("payments_page:"))
async def payments_pagination_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Handle pagination for payments list."""
try:
page = int(callback.data.split(":")[1])
await view_payments_handler(callback, i18n_data, settings, session, page)
except (ValueError, IndexError):
await callback.answer("Error processing pagination.", show_alert=True)
@router.callback_query(F.data == "payments_export_csv")
async def export_payments_csv_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
"""Export all successful payments to CSV file."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
# Get all successful payments
all_payments = await payment_dal.get_all_succeeded_payments_with_user(session)
if not all_payments:
await callback.answer(_("admin_no_payments_to_export"), show_alert=True)
return
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow(
[
_("admin_csv_payment_id"),
_("admin_csv_user_id"),
_("admin_csv_username"),
_("admin_csv_first_name"),
_("admin_csv_amount"),
_("admin_csv_currency"),
_("admin_csv_provider"),
_("admin_csv_status"),
_("admin_csv_description"),
_("admin_csv_units"),
"sale_mode",
"tariff_key",
"purchased_gb",
_("admin_csv_created_at"),
_("admin_csv_provider_payment_id"),
]
)
# Write payment data
for payment in all_payments:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
if (payment.purchased_gb is not None) and units_val not in ("", None):
try:
units_val = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
except Exception:
units_val = payment.purchased_gb or payment.subscription_duration_months or ""
writer.writerow(
[
payment.payment_id,
payment.user_id,
payment.user.username if payment.user and payment.user.username else "",
payment.user.first_name if payment.user and payment.user.first_name else "",
payment.amount,
payment.currency,
payment.provider or "",
payment.status,
payment.description or "",
units_val,
payment.sale_mode or "",
payment.tariff_key or "",
payment.purchased_gb or "",
payment.created_at.strftime("%Y-%m-%d %H:%M:%S") if payment.created_at else "",
payment.provider_payment_id or "",
]
)
# Prepare file
csv_content = output.getvalue().encode("utf-8-sig") # UTF-8 with BOM for Excel
output.close()
# Generate filename with current date
current_time = datetime.now().strftime("%Y-%m-%d_%H-%M")
filename = f"payments_export_{current_time}.csv"
# Send file
from aiogram.types import BufferedInputFile
file = BufferedInputFile(csv_content, filename=filename)
await callback.message.reply_document(
document=file, caption=_("admin_payments_export_success", count=len(all_payments))
)
await callback.answer(_("admin_export_sent"), show_alert=False)
except Exception as e:
logging.error(f"Failed to export payments CSV: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
@router.callback_query(F.data == "noop")
async def noop_handler(callback: types.CallbackQuery):
"""Handle no-op callback (for pagination display)."""
await callback.answer()
@@ -0,0 +1,11 @@
from aiogram import Router
from . import bulk, create, manage
promo_router_aggregate = Router(name="promo_features_router")
promo_router_aggregate.include_router(create.router)
promo_router_aggregate.include_router(manage.router)
promo_router_aggregate.include_router(bulk.router)
__all__ = ("promo_router_aggregate",)
+542
View File
@@ -0,0 +1,542 @@
import csv
import io
import logging
import random
import string
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
router = Router(name="promo_bulk_router")
async def create_bulk_promo_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
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("Error preparing bulk promo creation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for quantity
prompt_text = _("admin_bulk_promo_step1_quantity")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception as e:
logging.warning(f"Could not edit message for bulk promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_bulk_promo_quantity)
def generate_unique_promo_code(length: int = 8) -> str:
"""Generate a unique random promo code"""
characters = string.ascii_uppercase + string.digits
return "".join(random.choice(characters) for _ in range(length))
# Step 1: Process quantity
@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text)
async def process_bulk_promo_quantity_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
quantity = int(message.text.strip())
if not (1 <= quantity <= 100):
await message.answer(_("admin_bulk_promo_invalid_quantity"))
return
await state.update_data(quantity=quantity)
# Step 2: Ask for bonus days
prompt_text = _("admin_bulk_promo_step2_bonus_days", quantity=quantity)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo quantity: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text)
async def process_bulk_promo_bonus_days_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_("admin_promo_invalid_bonus_days"))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step3_max_activations",
quantity=data.get("quantity"),
bonus_days=bonus_days,
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text)
async def process_bulk_promo_max_activations_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_("admin_promo_invalid_max_activations"))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step4_validity",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations,
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity"),
callback_data="bulk_promo_unlimited_validity",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days"), callback_data="bulk_promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(text=_("admin_back_to_panel"), callback_data="admin_action:main")
)
await message.answer(prompt_text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(
F.data == "bulk_promo_unlimited_validity",
StateFilter(AdminStates.waiting_for_bulk_promo_validity_days),
)
async def process_bulk_promo_unlimited_validity(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
await state.update_data(validity_days=None)
await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(
F.data == "bulk_promo_set_validity",
StateFilter(AdminStates.waiting_for_bulk_promo_validity_days),
)
async def process_bulk_promo_set_validity(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings
):
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("Error processing validity.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_enter_validity_days",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations"),
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text)
async def process_bulk_promo_validity_days_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_("admin_promo_invalid_validity_days"))
return
await state.update_data(validity_days=validity_days)
await create_bulk_promo_codes_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing bulk promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_bulk_promo_codes_final(
callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
"""Final step - create multiple promo codes in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
data = await state.get_data()
quantity = data["quantity"]
# Show progress message
progress_text = _("admin_bulk_promo_creating", quantity=quantity)
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(progress_text, parse_mode="HTML")
except Exception:
await callback_or_message.message.answer(progress_text, parse_mode="HTML")
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(progress_text, parse_mode="HTML")
# Generate and create promo codes
created_codes = []
failed_codes = []
for i in range(quantity):
try:
# Generate unique code
attempts = 0
while attempts < 10: # Max 10 attempts to generate unique code
promo_code = generate_unique_promo_code()
existing_promo = await promo_code_dal.get_promo_code_by_code(
session, promo_code
)
if not existing_promo:
break
attempts += 1
if attempts >= 10:
failed_codes.append(f"Код #{i + 1} (не удалось сгенерировать уникальный)")
continue
# Prepare promo code data
promo_data = {
"code": promo_code,
"bonus_days": data["bonus_days"],
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc),
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(
days=data["validity_days"]
)
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
created_codes.append(created_promo.code)
except Exception as e:
logging.error(f"Error creating bulk promo code #{i + 1}: {e}")
failed_codes.append(f"Код #{i + 1} ({str(e)[:50]})")
await session.commit()
# Success message
success_lines = [
_("admin_bulk_promo_created_title"),
_("admin_bulk_promo_created_stats", created=len(created_codes), total=quantity),
]
if data.get("validity_days"):
validity_text = f"{data['validity_days']} дней"
else:
validity_text = _("admin_promo_unlimited")
success_lines.append(
_(
"admin_bulk_promo_settings",
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
validity=validity_text,
)
)
# Create CSV file with promo codes if any were created
csv_file = None
if created_codes:
success_lines.append(f"\n🎟 <b>Создано {len(created_codes)} промокодов</b>")
success_lines.append("📄 CSV файл с промокодами отправлен отдельным сообщением")
# Create CSV file
output = io.StringIO()
writer = csv.writer(output)
# CSV headers
writer.writerow(
[
"Промокод",
"Бонусные дни",
"Макс. активации",
"Действителен до",
"Команда для старта",
"Ссылка для активации",
]
)
# Get real bot username
bot_username = "your_bot" # fallback
try:
if hasattr(callback_or_message, "message"):
bot = callback_or_message.message.bot
else:
bot = callback_or_message.bot
bot_info = await bot.get_me()
bot_username = bot_info.username or "your_bot"
except Exception as e:
logging.error(f"Failed to get bot username for CSV links: {e}")
bot_username = "your_bot"
for code in created_codes:
# Determine validity info
if data.get("validity_days"):
valid_until = (
datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
).strftime("%Y-%m-%d %H:%M:%S")
else:
valid_until = "Без ограничений"
start_command = f"/start promo_{code}"
telegram_link = f"https://t.me/{bot_username}?start=promo_{code}"
writer.writerow(
[
code,
data["bonus_days"],
data["max_activations"],
valid_until,
start_command,
telegram_link,
]
)
output.seek(0)
# Create file for sending
filename = f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
csv_file = types.BufferedInputFile(
output.getvalue().encode("utf-8-sig"), # BOM for correct Excel display
filename=filename,
)
if failed_codes:
success_lines.append(f"\n❌ <b>Ошибки ({len(failed_codes)}):</b>")
for error in failed_codes[:5]: # Show first 5 errors
success_lines.append(error)
if len(failed_codes) > 5:
success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок")
success_text = "\n".join(success_lines)
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
message_obj = callback_or_message.message
except Exception:
message_obj = await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback_or_message.answer()
else: # Message
message_obj = await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
# Send CSV file if created
if csv_file:
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый" # noqa: E501
await message_obj.answer_document(csv_file, caption=csv_caption)
await state.clear()
except Exception as e:
logging.error(f"Error creating bulk promo codes: {e}")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, "message"): # CallbackQuery
await callback_or_message.message.answer(error_text)
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
# Cancel bulk promo creation
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_bulk_promo_quantity,
AdminStates.waiting_for_bulk_promo_bonus_days,
AdminStates.waiting_for_bulk_promo_max_activations,
AdminStates.waiting_for_bulk_promo_validity_days,
),
)
async def cancel_bulk_promo_creation_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer(_("admin_bulk_promo_creation_cancelled"))
await state.clear()
+412
View File
@@ -0,0 +1,412 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
router = Router(name="promo_create_router")
async def create_promo_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
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("Error preparing promo creation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
prompt_text = _("admin_promo_step1_code")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception as e:
logging.warning(f"Could not edit message for promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
# Step 1: Process promo code
@router.message(AdminStates.waiting_for_promo_code, F.text)
async def process_promo_code_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
code_str = message.text.strip().upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
await message.answer(_("admin_promo_invalid_code_format"))
return
# Check if code already exists
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
if existing_promo:
await message.answer(_("admin_promo_code_already_exists"))
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _("admin_promo_step2_bonus_days", code=code_str)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_promo_bonus_days, F.text)
async def process_promo_bonus_days_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_("admin_promo_invalid_bonus_days"))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations", code=data.get("promo_code"), bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(
message: types.Message, state: FSMContext, i18n_data: dict, settings: Settings
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_("admin_promo_invalid_max_activations"))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations,
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity"), callback_data="promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days"), callback_data="promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(text=_("admin_back_to_panel"), callback_data="admin_action:main")
)
await message.answer(prompt_text, reply_markup=builder.as_markup(), parse_mode="HTML")
await state.set_state(AdminStates.waiting_for_promo_validity_days)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(
F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_unlimited_validity(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
await state.update_data(validity_days=None)
await create_promo_code_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(
F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days)
)
async def process_promo_set_validity(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, settings: Settings
):
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("Error processing validity.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations"),
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_promo_validity_days, F.text)
async def process_promo_validity_days_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_("admin_promo_invalid_validity_days"))
return
await state.update_data(validity_days=validity_days)
await create_promo_code_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_("admin_promo_invalid_number"))
except Exception as e:
logging.error(f"Error processing promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_promo_code_final(
callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
"""Final step - create the promo code in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
data = await state.get_data()
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc),
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(
days=data["validity_days"]
)
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(
f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}"
)
# Success message
valid_until_str = (
_("admin_promo_unlimited")
if not data.get("validity_days")
else f"{data['validity_days']} дней"
)
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str,
)
if hasattr(callback_or_message, "message"): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception:
await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await state.clear()
except Exception as e:
logging.error(f"Error creating promo code: {e}")
error_text = _("error_occurred_try_again")
if hasattr(callback_or_message, "message"): # CallbackQuery
await callback_or_message.message.answer(error_text)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
# Cancel promo creation
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
)
async def cancel_promo_creation_state_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
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")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
await callback.answer(_("admin_promo_creation_cancelled"))
await state.clear()
+629
View File
@@ -0,0 +1,629 @@
import csv
import io
from datetime import datetime, timedelta, timezone
from typing import Optional
from aiogram import F, Router, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from config.settings import Settings
from db.dal import promo_code_dal
from db.models import PromoCode
router = Router(name="promo_manage_router")
def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_lang: str):
"""Determine promo code status and return emoji + text"""
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
return "", _("admin_promo_status_expired")
elif promo.current_activations >= promo.max_activations:
return "🔄", _("admin_promo_status_used_up")
elif promo.is_active:
return "", _("admin_promo_status_active")
else:
return "🚫", _("admin_promo_status_inactive")
async def get_promo_detail_text_and_keyboard(
promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str
):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return None, None
status_emoji, status = get_promo_status_emoji_and_text(promo, i18n, current_lang)
validity = _("admin_promo_valid_indefinitely")
if promo.valid_until:
validity = promo.valid_until.strftime("%d.%m.%Y %H:%M")
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
text = "\n".join(
[
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_(
"admin_promo_card_activations",
current=promo.current_activations,
max=promo.max_activations,
),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
_("admin_promo_card_created", created=created),
_("admin_promo_card_created_by", creator=promo.created_by_admin_id),
]
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_view_activations_button"),
callback_data=f"promo_activations:{promo_id}:0",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"
)
)
return text, builder.as_markup()
async def view_promo_codes_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = (
f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
if not promo_models
else "\n".join(
[_("admin_active_promos_list_header"), ""]
+ [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" # noqa: E501
for p in promo_models
]
)
)
await callback.message.edit_text(
text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML"
)
await callback.answer()
async def promo_management_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
page: int = 0,
):
current_lang = i18n_data.get("current_language", "ru")
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = 10 # Количество промокодов на странице
offset = page * page_size
# Получаем общее количество промокодов
total_count = await promo_code_dal.get_promo_codes_count(session)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
promo_models = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=page_size, offset=offset
)
if not promo_models and page == 0:
await callback.message.edit_text(
_("admin_promo_management_empty"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
await callback.answer()
return
builder = InlineKeyboardBuilder()
for promo in promo_models:
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
button_text = (
f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
)
builder.row(
InlineKeyboardButton(
text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"
)
)
# Добавляем кнопки пагинации если есть больше одной страницы
if total_pages > 1:
pagination_buttons = []
if page > 0:
pagination_buttons.append(
InlineKeyboardButton(
text=_("prev_page_button"), callback_data=f"promo_management:{page - 1}"
)
)
if page < total_pages - 1:
pagination_buttons.append(
InlineKeyboardButton(
text=_("next_page_button"), callback_data=f"promo_management:{page + 1}"
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
# Добавляем кнопки экспорта и возврата
builder.row(
InlineKeyboardButton(
text=_("admin_promo_export_csv_button"), callback_data="promo_export_all"
)
)
builder.row(
InlineKeyboardButton(
text=_("back_to_admin_panel_button"), callback_data="admin_action:main"
)
)
# Формируем заголовок с информацией о страницах
title = _("admin_promo_management_title")
if total_pages > 1:
title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}" # noqa: E501
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data.startswith("promo_management:"))
async def promo_management_pagination_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
try:
page = int(callback.data.split(":")[1])
await promo_management_handler(callback, i18n_data, settings, session, page)
except (ValueError, IndexError):
await callback.answer("Error processing pagination.", show_alert=True)
@router.callback_query(F.data.startswith("promo_detail:"))
async def promo_detail_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
await callback.answer("Error processing request.", show_alert=True)
return
try:
promo_id = int(callback.data.split(":")[1])
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
await callback.answer(
i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True
)
except (ValueError, IndexError):
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data.startswith("promo_toggle:"))
async def promo_toggle_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
new_status = not promo.is_active
if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
await session.commit()
status_text = (
_("admin_promo_status_activated")
if new_status
else _("admin_promo_status_deactivated")
)
await callback.answer(
_("admin_promo_toggle_success", code=promo.code, status=status_text)
)
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
@router.callback_query(F.data.startswith("promo_activations:"))
async def promo_activations_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
parts = callback.data.split(":")
promo_id = int(parts[1])
page = int(parts[2])
page_size = settings.LOGS_PAGE_SIZE
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
total_activations = await promo_code_dal.count_promo_activations_by_code_id(
session, promo_id
)
activations = await promo_code_dal.get_promo_activations_by_code_id(
session, promo_id, limit=page_size, offset=page * page_size
)
builder = InlineKeyboardBuilder()
if not activations:
text = _("admin_promo_no_activations", code=promo.code)
else:
text = _("admin_promo_activations_header", code=promo.code) + "\n\n"
text += "\n".join(
[
_(
"admin_promo_activation_item",
user_id=a.user_id,
date=a.activated_at.strftime("%d.%m.%Y %H:%M"),
)
for a in activations
]
)
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(
text="⬅️", callback_data=f"promo_activations:{promo_id}:{page - 1}"
)
)
if (page + 1) * page_size < total_activations:
nav_buttons.append(
InlineKeyboardButton(
text="➡️", callback_data=f"promo_activations:{promo_id}:{page + 1}"
)
)
if nav_buttons:
builder.row(*nav_buttons)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_detail_button"),
callback_data=f"promo_detail:{promo_id}",
)
)
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data.startswith("promo_export:"))
async def promo_export_activations_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
export_lang = "en"
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
if not activations:
return await callback.answer(
_("admin_promo_no_activations", code=promo.code), show_alert=True
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["User ID", "Activation Date"])
for act in activations:
writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
output.seek(0)
file = types.BufferedInputFile(
output.getvalue().encode("utf-8"), filename=f"promo_{promo.code}_activations.csv"
)
# Force English caption for exports
await callback.message.answer_document(
file, caption=i18n.gettext(export_lang, "admin_promo_export_caption", code=promo.code)
)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data == "promo_export_all")
async def promo_export_all_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
export_lang = "en"
try:
await callback.answer(
i18n.gettext(export_lang, "admin_promo_export_all_generating"), show_alert=True
)
# Получаем все промокоды
all_promos = await promo_code_dal.get_all_promo_codes_with_details(
session, limit=10000, offset=0
)
output = io.StringIO()
writer = csv.writer(output)
# CSV headers (forced to English)
writer.writerow(
[
i18n.gettext(export_lang, "admin_promo_csv_code"),
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
i18n.gettext(export_lang, "admin_promo_csv_is_active"),
i18n.gettext(export_lang, "admin_promo_csv_valid_until"),
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
]
)
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Формируем данные для CSV
row = [
promo.code,
promo.bonus_days,
promo.max_activations,
promo.current_activations,
status_text,
i18n.gettext(export_lang, "csv_yes")
if promo.is_active
else i18n.gettext(export_lang, "csv_no"),
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S")
if promo.valid_until
else i18n.gettext(export_lang, "admin_promo_valid_indefinitely"),
promo.created_at.strftime("%Y-%m-%d %H:%M:%S") if promo.created_at else "N/A",
promo.created_by_admin_id or "N/A",
]
writer.writerow(row)
output.seek(0)
# Создаем файл для отправки
filename = f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
file = types.BufferedInputFile(
output.getvalue().encode("utf-8-sig"), # BOM для корректного отображения в Excel
filename=filename,
)
caption = i18n.gettext(export_lang, "admin_promo_export_all_caption", count=len(all_promos))
await callback.message.answer_document(file, caption=caption)
except Exception as e:
await callback.answer(f"❌ Export error: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if promo:
await session.commit()
await callback.answer(
_("admin_promo_deleted_success", code=promo.code), show_alert=True
)
await promo_management_handler(callback, i18n_data, settings, session, 0)
else:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
# --- Promo Edit Handlers ---
@router.callback_query(F.data.startswith("promo_edit_select:"))
async def promo_edit_select_handler(
callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_bonus_days"),
callback_data=f"promo_edit_field:bonus_days:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_max_activations"),
callback_data=f"promo_edit_field:max_activations:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_edit_validity"),
callback_data=f"promo_edit_field:valid_until:{promo_id}",
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"
)
)
await callback.message.edit_text(
_("admin_promo_edit_select_field"), reply_markup=builder.as_markup()
)
await callback.answer()
@router.callback_query(F.data.startswith("promo_edit_field:"))
async def promo_edit_field_handler(
callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days",
}
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
async def process_promo_edit_details(
message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict
):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
promo_id = data.get("promo_id")
field = data.get("field_to_edit")
try:
value = message.text
update_data = {}
if field == "bonus_days":
update_data["bonus_days"] = int(value)
elif field == "max_activations":
update_data["max_activations"] = int(value)
elif field == "valid_until":
if value.lower() in ["0", "вечно", "бессрочно", "indefinite"]:
update_data["valid_until"] = None
else:
days = int(value)
update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days)
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(
promo_id, session, i18n, current_lang
)
if text:
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
else:
await message.answer(_("error_occurred_try_again"))
await state.clear()
except (ValueError, TypeError):
await message.answer(_("admin_promo_invalid_input"))
# Don't clear state, let them try again
async def manage_promo_codes_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
await promo_management_handler(callback, i18n_data, settings, session)
+406
View File
@@ -0,0 +1,406 @@
import html
import logging
from typing import Dict, List, Optional
from aiogram import Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, payment_dal, user_dal
from db.models import PanelSyncStatus, Payment
router = Router(name="admin_statistics_router")
def _format_rating_user_label(
user_row: Dict[str, object], bot_username: Optional[str] = None
) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
user_id_text = str(user_id)
user_id_html = html.escape(user_id_text)
if bot_username:
safe_bot_username = html.escape(bot_username)
user_id_html = (
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
f"{user_id_html}</a>"
)
parts: List[str] = []
if username:
parts.append(f"@{html.escape(str(username))}")
elif first_name:
parts.append(html.escape(str(first_name)))
if not parts:
parts.append(f"ID {user_id_html}")
else:
parts.append(f"(ID {user_id_html})")
return " ".join(parts)
async def show_statistics_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
):
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("Error displaying statistics.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
# Enhanced user statistics
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(f"\n<b>👥 {_('admin_enhanced_users_stats_header')}</b>")
stats_text_parts.append(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 {_('admin_user_stats_banned_label')}: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_referral_label')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header')}</b>")
try:
async with PanelApiService(settings) as panel_service:
# Get system stats
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
logging.info(
f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" # noqa: E501
)
if system_stats:
users = system_stats.get("users", {})
status_counts = users.get("statusCounts", {})
online_stats = system_stats.get("onlineStats", {})
active_users = status_counts.get("ACTIVE", 0)
disabled_users = status_counts.get("DISABLED", 0)
expired_users = status_counts.get("EXPIRED", 0)
limited_users = status_counts.get("LIMITED", 0)
total_users = users.get("totalUsers", 0)
online_now = online_stats.get("onlineNow", 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: <b>{online_now}</b>")
stats_text_parts.append(
f"📊 {_('admin_panel_active_label')}: <b>{active_users}</b>"
)
stats_text_parts.append(
f"🔴 {_('admin_panel_disabled_label')}: <b>{disabled_users}</b>"
)
stats_text_parts.append(
f"{_('admin_panel_expired_label')}: <b>{expired_users}</b>"
)
stats_text_parts.append(
f"⚠️ {_('admin_panel_limited_label')}: <b>{limited_users}</b>"
)
stats_text_parts.append(
f"👥 {_('admin_panel_total_users_label')}: <b>{total_users}</b>"
)
# System resources
memory = system_stats.get("memory", {})
if memory:
memory_total = memory.get("total", 1)
memory_used = memory.get("used", 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
stats_text_parts.append(
f"💾 {_('admin_panel_memory_usage_label')}: <b>{memory_usage:.1f}%</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}")
# Bandwidth stats
if bandwidth_stats:
week_traffic = bandwidth_stats.get("bandwidthLastSevenDays", {})
month_traffic = bandwidth_stats.get("bandwidthLast30Days", {})
# Fallback to the actual key name from API if the above doesn't exist
if not month_traffic:
month_traffic = bandwidth_stats.get("bandwidthLastThirtyDays", {})
if week_traffic:
week_total = week_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_week_label')}: <b>{week_total}</b>"
)
if month_traffic:
month_total = month_traffic.get("current", "0 B")
stats_text_parts.append(
f"📊 {_('admin_panel_traffic_month_label')}: <b>{month_total}</b>"
)
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}")
# Nodes stats
if nodes_stats and "lastSevenDays" in nodes_stats:
last_seven_days = nodes_stats.get("lastSevenDays", [])
# Get unique node names from the data
unique_nodes = set()
for node_data in last_seven_days:
unique_nodes.add(node_data.get("nodeName", ""))
total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info
stats_text_parts.append(
f"🔗 {_('admin_panel_nodes_label')}: <b>{total_nodes_count}/{total_nodes_count}</b>" # noqa: E501
)
else:
# Use nodes total from system stats as fallback
nodes_info = system_stats.get("nodes", {}) if system_stats else {}
total_online = nodes_info.get("totalOnline", 0)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: <b>{total_online}</b>")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}")
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(f"\n<b>💰 {_('admin_financial_stats_header')}</b>")
stats_text_parts.append(
f"📅 {_('admin_financial_today_label')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>" # noqa: E501
)
last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user(
session, limit=5
)
if last_payments_models:
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ""
if payment.status in pending_statuses
else ""
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date_str = (
payment.created_at.strftime("%Y-%m-%d") if payment.created_at else "N/A"
)
stats_text_parts.append(
_(
"admin_stats_payment_item",
status_emoji=status_emoji,
amount=payment.amount,
currency=payment.currency,
user_info=user_info,
p_status=payment.status,
p_date=payment_date_str,
)
)
else:
stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}")
sync_status_model: Optional[PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(
session
)
if sync_status_model and sync_status_model.status != "never_run":
stats_text_parts.append(f"\n<b>{_('admin_stats_last_sync_header')}</b>")
sync_time_val = sync_status_model.last_sync_time
sync_time_str = sync_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if sync_time_val else "N/A"
details_val = sync_status_model.details
details_str = details_val or "N/A"
stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append(
f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" # noqa: E501
)
stats_text_parts.append(
f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
)
stats_text_parts.append(f" {_('admin_stats_sync_details_label')}: {details_str}")
else:
stats_text_parts.append(f"\n{_('admin_sync_status_never_run')}")
final_text = "\n".join(stats_text_parts)
try:
await callback.message.edit_text(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
except Exception as e_edit:
logging.error(f"Error editing message for statistics: {e_edit}", exc_info=True)
max_chunk_size = 4000
for i in range(0, len(final_text), max_chunk_size):
chunk = final_text[i : i + max_chunk_size]
is_last_chunk = (i + max_chunk_size) >= len(final_text)
try:
await callback.message.answer(
chunk,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n)
if is_last_chunk
else None,
parse_mode="HTML",
)
except Exception as e_chunk:
logging.error(f"Failed to send statistics chunk: {e_chunk}")
if i == 0:
await callback.message.answer(
_("error_displaying_statistics"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
break
async def show_user_ratings_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
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("Error displaying ratings.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
top_limit = 10
bot_username: Optional[str] = None
try:
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s", e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
session, limit=top_limit
)
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
text_parts: List[str] = [
_("admin_user_ratings_header", top_limit=top_limit),
"",
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
]
if traffic_top:
for idx, row in enumerate(traffic_top, start=1):
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
if lifetime_traffic_top:
for idx, row in enumerate(lifetime_traffic_top, start=1):
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
if invited_top:
for idx, row in enumerate(invited_top, start=1):
text_parts.append(
_(
"admin_user_ratings_invited_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
invited_count=int(row.get("invited_count") or 0),
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
if revenue_top:
for idx, row in enumerate(revenue_top, start=1):
text_parts.append(
_(
"admin_user_ratings_revenue_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
parse_mode="HTML",
)
+724
View File
@@ -0,0 +1,724 @@
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional, Union
from aiogram import Bot, Router, types
from aiogram.filters import Command
from sqlalchemy import or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, subscription_dal, user_dal
from db.models import Subscription
router = Router(name="admin_sync_router")
# Single-flight guard: panel sync runs concurrently with the bot, but only one
# sync at a time. Overlapping callers (startup, /sync, admin API) return early
# instead of queueing behind the running sync.
_sync_lock = asyncio.Lock()
def _normalize_panel_email(value: Optional[str]) -> Optional[str]:
email = (value or "").strip().lower()
return email or None
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
user_traffic.get("lifetimeUsedTrafficBytes") if isinstance(user_traffic, dict) else None
)
if raw_value is None:
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if raw_value is None:
return None
return int(raw_value)
except (TypeError, ValueError):
return None
async def _bind_panel_email_to_user(
session: AsyncSession,
*,
existing_user,
email_from_panel: Optional[str],
panel_uuid: str,
) -> tuple[object, bool]:
"""Bind panel email to a local user without violating the unique email index.
Panel email is treated as verified because it comes from the operator-managed
panel. If the same email already belongs to an email-only local account for
this panel user, merge that account into the Telegram/local user.
"""
if not email_from_panel:
return existing_user, False
if existing_user.email == email_from_panel:
if not existing_user.email_verified_at:
existing_user.email_verified_at = datetime.now(timezone.utc)
return existing_user, True
return existing_user, False
user_with_email = await user_dal.get_user_by_email(session, email_from_panel)
if user_with_email and user_with_email.user_id != existing_user.user_id:
can_merge_email_identity = (
not user_with_email.telegram_id
and user_with_email.panel_user_uuid in (None, panel_uuid)
and (not existing_user.email or existing_user.email == email_from_panel)
)
if can_merge_email_identity:
try:
merged_user = await user_dal.merge_users(
session,
source_user_id=user_with_email.user_id,
target_user_id=existing_user.user_id,
)
if not merged_user.email:
merged_user.email = email_from_panel
if not merged_user.email_verified_at:
merged_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.", # noqa: E501
user_with_email.user_id,
merged_user.user_id,
email_from_panel,
panel_uuid,
)
return merged_user, True
except Exception as merge_error:
logging.warning(
"Could not merge email-only user %s into user %s for panel email %s: %s",
user_with_email.user_id,
existing_user.user_id,
email_from_panel,
merge_error,
)
return existing_user, False
logging.warning(
"Panel email %s for panel UUID %s is already linked to local user %s; "
"skipping email binding for user %s.",
email_from_panel,
panel_uuid,
user_with_email.user_id,
existing_user.user_id,
)
return existing_user, False
existing_user.email = email_from_panel
existing_user.email_verified_at = datetime.now(timezone.utc)
logging.info(
"Bound panel email %s to local user %s for panel UUID %s.",
email_from_panel,
existing_user.user_id,
panel_uuid,
)
return existing_user, True
async def perform_sync(
panel_service: PanelApiService,
session: AsyncSession,
settings: Settings,
i18n_instance: JsonI18n,
) -> dict:
"""Single-flight entry point — skips when another sync is already running."""
if _sync_lock.locked():
logging.info("perform_sync: skipped because another sync is already in progress")
return {
"status": "skipped",
"details": "Another sync run is already in progress.",
"errors": [],
"users_processed": 0,
"subs_synced": 0,
}
async with _sync_lock:
return await _perform_sync_impl(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n_instance,
)
async def _perform_sync_impl(
panel_service: PanelApiService,
session: AsyncSession,
settings: Settings,
i18n_instance: JsonI18n,
) -> dict:
"""
Perform panel synchronization and return results
Returns dict with status, details, and sync statistics
"""
panel_records_checked = 0
users_found_in_db = 0
users_updated = 0
subscriptions_synced_count = 0
sync_errors = []
# Additional counters for detailed logging
users_without_telegram_id = 0
users_not_found_in_db = 0
users_created = 0
users_uuid_updated = 0
subscriptions_created = 0
subscriptions_updated = 0
try:
panel_users_data = await panel_service.get_all_panel_users()
if panel_users_data is None:
error_msg = "Failed to fetch users from panel or panel API issue."
sync_errors.append(error_msg)
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
await session.commit()
return {"status": "failed", "details": error_msg, "errors": sync_errors}
if not panel_users_data:
status_msg = "No users found in the panel to sync."
await panel_sync_dal.update_panel_sync_status(session, "success", status_msg, 0, 0)
await session.commit()
return {
"status": "success",
"details": status_msg,
"users_synced": 0,
"subs_synced": 0,
}
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
for panel_user_dict in panel_users_data:
try:
panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid")
panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
telegram_id_from_panel = panel_user_dict.get("telegramId")
email_from_panel = _normalize_panel_email(panel_user_dict.get("email"))
if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
continue
# Track users without telegram ID
if not telegram_id_from_panel:
users_without_telegram_id += 1
# Try to find existing user in local DB
existing_user = None
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_telegram_id(
session, telegram_id_from_panel
)
if not existing_user:
existing_user = await user_dal.get_user_by_id(
session, telegram_id_from_panel
)
if existing_user:
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
# If not found by telegram ID, try to find by panel UUID.
# The panel UUID is the strongest local link for subscription sync.
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if existing_user:
logging.debug(
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" # noqa: E501
)
# Update telegram ID if it was missing in panel data but we have local user
if (
telegram_id_from_panel
and existing_user.user_id != telegram_id_from_panel
):
logging.warning(
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" # noqa: E501
)
# Finally, fall back to email. This mainly catches panel users that
# were first imported as email-only identities.
if not existing_user and email_from_panel:
existing_user = await user_dal.get_user_by_email(session, email_from_panel)
if existing_user:
logging.debug(f"Found user by email {email_from_panel}")
if not existing_user:
users_not_found_in_db += 1
if telegram_id_from_panel:
# Create new user if they have telegram_id
try:
user_data = {
"user_id": telegram_id_from_panel,
"telegram_id": telegram_id_from_panel,
"email": email_from_panel,
"email_verified_at": (
datetime.now(timezone.utc) if email_from_panel else None
),
"username": None, # Username will be updated when user interacts with bot # noqa: E501
"first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
"language_code": "ru", # Default language
"panel_user_uuid": panel_uuid,
"is_banned": False,
"referred_by_id": None,
}
new_user, was_created = await user_dal.create_user(session, user_data)
if was_created:
users_created += 1
logging.info(
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}" # noqa: E501
)
existing_user = new_user
except Exception as e_create:
sync_errors.append(
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
)
logging.error(
f"Error creating user {telegram_id_from_panel}: {e_create}"
)
continue
elif email_from_panel:
try:
new_user, was_created = await user_dal.create_email_user(
session,
email=email_from_panel,
language_code="ru",
)
new_user.panel_user_uuid = panel_uuid
if was_created:
users_created += 1
logging.info(
f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" # noqa: E501
)
existing_user = new_user
except Exception as e_create_email:
sync_errors.append(
f"Error creating email user {email_from_panel}: {str(e_create_email)}" # noqa: E501
)
logging.error(
f"Error creating email user {email_from_panel}: {e_create_email}"
)
continue
else:
logging.debug(
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" # noqa: E501
)
continue
# User found in local DB
users_found_in_db += 1
user_was_updated = False
# Get the actual user_id for subscription operations
actual_user_id = existing_user.user_id
# Update panel UUID if different
if existing_user.panel_user_uuid != panel_uuid:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
existing_user, email_was_bound = await _bind_panel_email_to_user(
session,
existing_user=existing_user,
email_from_panel=email_from_panel,
panel_uuid=panel_uuid,
)
if email_was_bound:
user_was_updated = True
if telegram_id_from_panel and existing_user.telegram_id != telegram_id_from_panel:
existing_user.telegram_id = telegram_id_from_panel
user_was_updated = True
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
lifetime_used is not None
and existing_user.lifetime_used_traffic_bytes != lifetime_used
):
existing_user.lifetime_used_traffic_bytes = lifetime_used
user_was_updated = True
# Ensure panel description contains Telegram fields
try:
if panel_uuid and existing_user:
description_text = "\n".join(
line
for line in [
existing_user.email or "",
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
]
if line
)
# Update description only when it differs from the current one on panel
current_panel_description = (
panel_user_dict.get("description") or ""
).strip()
desired_description = description_text.strip()
if desired_description and desired_description != current_panel_description:
await panel_service.update_user_details_on_panel(
panel_uuid,
{
"description": description_text,
**(
{"email": existing_user.email}
if existing_user.email
else {}
),
**(
{"telegramId": existing_user.telegram_id}
if existing_user.telegram_id
else {}
),
},
)
except Exception as e_desc:
logging.warning(
f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501
)
# Sync subscription data
panel_expire_at_iso = panel_user_dict.get("expireAt")
panel_status = panel_user_dict.get("status", "UNKNOWN")
if panel_expire_at_iso:
try:
panel_expire_at = datetime.fromisoformat(
panel_expire_at_iso.replace("Z", "+00:00")
)
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
subscription_uuid_from_panel = panel_user_dict.get(
"subscriptionUuid"
) or panel_user_dict.get("shortUuid")
if subscription_uuid_from_panel:
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные # noqa: E501
if panel_status == "ACTIVE":
await session.execute(
update(Subscription)
.where(
Subscription.panel_user_uuid == panel_uuid,
Subscription.is_active.is_(True),
or_(
Subscription.panel_subscription_uuid
!= subscription_uuid_from_panel,
Subscription.panel_subscription_uuid.is_(None),
),
)
.values(
is_active=False,
status_from_panel="INACTIVE",
)
)
# Try to find subscription by its panel_subscription_uuid first (idempotent) # noqa: E501
existing_sub_by_uuid = (
await subscription_dal.get_subscription_by_panel_subscription_uuid(
session, subscription_uuid_from_panel
)
)
if existing_sub_by_uuid:
# Atomic update of all relevant fields
await subscription_dal.update_subscription(
session,
existing_sub_by_uuid.subscription_id,
{
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
)
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.debug(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " # noqa: E501
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
)
else:
# Create a new subscription only when we have a concrete subscription UUID # noqa: E501
sub_payload = {
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"panel_subscription_uuid": subscription_uuid_from_panel,
# Do not guess precise start_date from panel; keep nullable
"start_date": None,
"end_date": panel_expire_at,
"duration_months": None,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
"auto_renew_enabled": False,
}
created_sub = await subscription_dal.upsert_subscription(
session, sub_payload
)
subscriptions_synced_count += 1
subscriptions_created += 1
user_was_updated = True
logging.debug(
f"Created subscription {created_sub.subscription_id} "
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}" # noqa: E501
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID # noqa: E501
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
)
if active_sub:
await subscription_dal.update_subscription(
session,
active_sub.subscription_id,
{
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
)
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.debug(
f"Updated active subscription {active_sub.subscription_id} "
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501
)
else:
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent # noqa: E501
logging.debug(
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}" # noqa: E501
)
except Exception as e:
sync_errors.append(
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
)
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
if user_was_updated:
users_updated += 1
except Exception as e_user:
sync_errors.append(
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}" # noqa: E501
)
logging.error(f"Error syncing user: {e_user}")
# Update sync status
status = "completed_with_errors" if sync_errors else "completed"
# Build additional stats
default_lang = settings.DEFAULT_LANGUAGE
additional_stats = ""
if users_without_telegram_id > 0:
additional_stats += i18n_instance.gettext(
default_lang,
"admin_sync_no_telegram_id",
count=users_without_telegram_id,
)
if users_not_found_in_db > 0:
additional_stats += i18n_instance.gettext(
default_lang,
"admin_sync_not_found_in_db",
count=users_not_found_in_db,
)
if sync_errors:
additional_stats += i18n_instance.gettext(
default_lang, "admin_sync_errors", count=len(sync_errors)
)
# Build full details using localization
details = i18n_instance.gettext(
default_lang,
"admin_sync_details",
panel_records_checked=panel_records_checked,
users_found_in_db=users_found_in_db,
users_created=users_created,
users_updated=users_updated,
subscriptions_synced_count=subscriptions_synced_count,
subscriptions_created=subscriptions_created,
subscriptions_updated=subscriptions_updated,
additional_stats=additional_stats,
)
await panel_sync_dal.update_panel_sync_status(
session,
status,
details,
panel_records_checked,
subscriptions_synced_count,
)
await session.commit()
# Detailed logging summary
logging.info("Sync completed - Summary:")
logging.info(f" Panel records checked: {panel_records_checked}")
logging.info(f" Users without telegramId: {users_without_telegram_id}")
logging.info(f" Users not found in local DB: {users_not_found_in_db}")
logging.info(f" Users found in local DB: {users_found_in_db}")
logging.info(f" Users created: {users_created}")
logging.info(f" Users with UUID updated: {users_uuid_updated}")
logging.info(f" Users updated overall: {users_updated}")
logging.info(f" Subscriptions total synced: {subscriptions_synced_count}")
logging.info(f" Subscriptions created: {subscriptions_created}")
logging.info(f" Subscriptions updated: {subscriptions_updated}")
logging.info(f" Sync errors: {len(sync_errors)}")
return {
"status": status,
"details": details,
"users_processed": panel_records_checked,
"users_synced": users_found_in_db,
"users_created": users_created,
"subs_synced": subscriptions_synced_count,
"errors": sync_errors,
}
except Exception as e_sync_global:
await session.rollback()
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
await panel_sync_dal.update_panel_sync_status(
session,
"failed",
error_detail,
panel_records_checked,
subscriptions_synced_count,
)
return {
"status": "failed",
"details": error_detail,
"errors": [str(e_sync_global)],
}
@router.message(Command("sync"))
async def sync_command_handler(
message_event: Union[types.Message, types.CallbackQuery],
bot: Bot,
settings: Settings,
i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in sync_command_handler")
if isinstance(message_event, types.Message):
await message_event.answer("Language error.")
elif isinstance(message_event, types.CallbackQuery):
await message_event.answer("Language error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
target_chat_id = (
message_event.chat.id
if isinstance(message_event, types.Message)
else (message_event.message.chat.id if message_event.message else None)
)
if not target_chat_id:
logging.error("Sync handler: could not determine target_chat_id.")
if isinstance(message_event, types.CallbackQuery):
await message_event.answer("Error initiating sync.", show_alert=True)
return
if isinstance(message_event, types.Message):
await message_event.answer(_("sync_started_simple"))
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
# Use the extracted perform_sync function
try:
sync_result = await perform_sync(panel_service, session, settings, i18n)
status = sync_result.get("status")
details = sync_result.get("details", "No details available")
errors = sync_result.get("errors", [])
# Simple confirmation message to admin
if status == "failed":
await bot.send_message(target_chat_id, _("sync_failed_simple"))
elif status == "completed_with_errors":
await bot.send_message(
target_chat_id,
_("sync_errors_simple", errors_count=len(errors)),
)
else:
await bot.send_message(target_chat_id, _("sync_success_simple"))
# Send notification to log channel with proper thread handling
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync(
status,
details,
sync_result.get("users_processed", 0),
sync_result.get("subs_synced", 0),
)
except Exception as e_notification:
logging.error(f"Failed to send sync notification: {e_notification}")
except Exception as e_sync_global:
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
await bot.send_message(target_chat_id, _("sync_critical_error"))
# Send notification to log channel about failure
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync("failed", str(e_sync_global), 0, 0)
except Exception as e_notification:
logging.error(f"Failed to send sync failure notification: {e_notification}")
@router.message(Command("syncstatus"))
async def sync_status_command_handler(
message: types.Message, i18n_data: dict, settings: Settings, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.answer("Language error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
status_record_model = await panel_sync_dal.get_panel_sync_status(session)
response_text = ""
if status_record_model:
last_time_val = status_record_model.last_sync_time
last_time_str = last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
details_val = status_record_model.details
details_str = details_val or "N/A"
response_text = (
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
f" {_('admin_stats_sync_time')}: {last_time_str}\n"
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" # noqa: E501
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
f" {_('admin_stats_sync_details_label')}: {details_str}"
)
else:
response_text = _("admin_sync_status_never_run")
await message.answer(response_text, parse_mode="HTML")
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
import logging
from typing import List, Optional
from aiogram import Bot, Router
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
router = Router(name="inline_mode_router")
@router.inline_query()
async def inline_query_handler(
inline_query: InlineQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
"""Handle inline queries for referral links and admin statistics"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
user_id = inline_query.from_user.id
query = inline_query.query.lower().strip()
results: List[InlineQueryResultArticle] = []
# Check if user is admin
is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False
try:
# For all users: referral functionality
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
referral_result = await create_referral_result(
inline_query,
bot,
referral_service,
i18n,
current_lang,
settings,
session,
)
if referral_result:
results.append(referral_result)
# For admins: statistics
if is_admin and (
not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query
):
stats_results = await create_admin_stats_results(session, i18n, current_lang, settings)
results.extend(stats_results)
# Limit results to 50 (Telegram limit)
results = results[:50]
await inline_query.answer(
results=results,
cache_time=30, # Cache for 30 seconds
is_personal=True, # Results are personalized
)
except Exception as e:
logging.error(f"Error handling inline query from user {user_id}: {e}")
# Send empty results in case of error
await inline_query.answer(results=[], cache_time=10)
async def create_referral_result(
inline_query: InlineQuery,
bot: Bot,
referral_service: ReferralService,
i18n_instance,
lang: str,
settings: Settings,
session: AsyncSession,
) -> Optional[InlineQueryResultArticle]:
"""Create referral link result for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
return None
user_id = inline_query.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, user_id
)
if not referral_link:
logging.warning("Could not produce referral link for inline user %s", user_id)
return None
# Create message content (use same text as friend message)
message_text = _("referral_friend_message", referral_link=referral_link)
return InlineQueryResultArticle(
id="referral_link",
title=_("inline_referral_title"),
description=_("inline_referral_description"),
input_message_content=InputTextMessageContent(
message_text=message_text, disable_web_page_preview=True
),
thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating referral result: {e}")
return None
async def create_admin_stats_results(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> List[InlineQueryResultArticle]:
"""Create admin statistics results for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
results = []
try:
# Quick user stats
user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings)
if user_stats_result:
results.append(user_stats_result)
# Quick financial stats
financial_stats_result = await create_financial_stats_result(
session, i18n_instance, lang, settings
)
if financial_stats_result:
results.append(financial_stats_result)
# Quick system stats
system_stats_result = await create_system_stats_result(
session, i18n_instance, lang, settings
)
if system_stats_result:
results.append(system_stats_result)
except Exception as e:
logging.error(f"Error creating admin stats results: {e}")
return results
async def create_user_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create user statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.user_dal import get_enhanced_user_statistics
user_stats = await get_enhanced_user_statistics(session)
stats_text = _(
"inline_user_stats_message",
total=user_stats["total_users"],
active_today=user_stats["active_today"],
paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"],
inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"],
referral=user_stats["referral_users"],
)
return InlineQueryResultArticle(
id="admin_user_stats",
title=_("inline_admin_user_stats_title"),
description=_(
"inline_user_stats_description",
total=user_stats["total_users"],
active=user_stats["paid_subscriptions"],
),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating user stats result: {e}")
return None
async def create_financial_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create financial statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.payment_dal import get_financial_statistics
financial_stats = await get_financial_statistics(session)
stats_text = _(
"inline_financial_stats_message",
today=financial_stats["today_revenue"],
today_count=financial_stats["today_payments_count"],
week=financial_stats["week_revenue"],
month=financial_stats["month_revenue"],
all_time=financial_stats["all_time_revenue"],
)
return InlineQueryResultArticle(
id="admin_financial_stats",
title=_("inline_admin_financial_stats_title"),
description=_(
"inline_financial_description", today=f"{financial_stats['today_revenue']:.2f}"
),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating financial stats result: {e}")
return None
async def create_system_stats_result(
session: AsyncSession, i18n_instance, lang: str, settings: Settings
) -> Optional[InlineQueryResultArticle]:
"""Create panel statistics result with system/nodes/bandwidth info"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from bot.services.panel_api_service import PanelApiService
# Get panel stats similar to main statistics
async with PanelApiService(settings) as panel_service:
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
if system_stats:
users = system_stats.get("users", {})
status_counts = users.get("statusCounts", {})
online_stats = system_stats.get("onlineStats", {})
active_users = status_counts.get("ACTIVE", 0)
disabled_users = status_counts.get("DISABLED", 0)
expired_users = status_counts.get("EXPIRED", 0)
limited_users = status_counts.get("LIMITED", 0)
total_users = users.get("totalUsers", 0)
online_now = online_stats.get("onlineNow", 0)
# Memory usage
memory = system_stats.get("memory", {})
memory_usage = 0
if memory:
memory_total = memory.get("total", 1)
memory_used = memory.get("used", 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
# Bandwidth
week_traffic = "N/A"
month_traffic = "N/A"
if bandwidth_stats:
week_data = bandwidth_stats.get("bandwidthLastSevenDays", {})
month_data = bandwidth_stats.get(
"bandwidthLast30Days", {}
) or bandwidth_stats.get("bandwidthLastThirtyDays", {})
week_traffic = week_data.get("current", "N/A") if week_data else "N/A"
month_traffic = month_data.get("current", "N/A") if month_data else "N/A"
# Nodes
active_nodes = 0
total_nodes = 0
if nodes_stats and "lastSevenDays" in nodes_stats:
unique_nodes = set()
for node_data in nodes_stats.get("lastSevenDays", []):
unique_nodes.add(node_data.get("nodeName", ""))
total_nodes = len(unique_nodes)
active_nodes = total_nodes # Assume all are active
elif system_stats and "nodes" in system_stats:
active_nodes = system_stats.get("nodes", {}).get("totalOnline", 0)
total_nodes = active_nodes
stats_text = _(
"inline_system_stats_message",
online=online_now,
active=active_users,
disabled=disabled_users,
expired=expired_users,
limited=limited_users,
total=total_users,
memory=memory_usage,
week_traffic=week_traffic,
month_traffic=month_traffic,
active_nodes=active_nodes,
total_nodes=total_nodes,
)
else:
stats_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_("inline_admin_system_stats_title"),
description=_("inline_system_description", online=online_now, active=active_users),
input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL,
)
except Exception as e:
logging.error(f"Error creating system stats result: {e}")
# Fallback error message
error_text = _("inline_panel_stats_error")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_("inline_admin_system_stats_title"),
description=_("inline_system_error"),
input_message_content=InputTextMessageContent(
message_text=error_text, parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL,
)
return None
+14
View File
@@ -0,0 +1,14 @@
from aiogram import Router
from . import promo_user, referral, start, trial_handler
# TODO: after splitting subscription into a package, replace this import
from .subscription import router as subscription_router
user_router_aggregate = Router(name="user_router_aggregate")
user_router_aggregate.include_router(promo_user.router)
user_router_aggregate.include_router(trial_handler.router)
user_router_aggregate.include_router(start.router)
user_router_aggregate.include_router(subscription_router)
user_router_aggregate.include_router(referral.router)
+800
View File
@@ -0,0 +1,800 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32",
]
async def process_successful_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
)
return
db_user = None
try:
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
)
is_auto_renew = bool(
auto_renew_subscription_id_str
and not payment_db_id
and sale_mode_base == "subscription"
)
promo_code_id = (
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
)
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
if not yk_payment_id_from_hook:
logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
)
return
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
if not payment_record:
payment_record = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get("description")
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
exc_info=True,
)
return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
)
await payment_dal.update_payment_status_by_db_id(
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
)
return
except (TypeError, ValueError) as e:
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
if payment_db_id_str and payment_db_id_str.isdigit():
try:
await payment_dal.update_payment_status_by_db_id(
session,
int(payment_db_id_str),
"failed_metadata_error",
payment_info_from_webhook.get("id"),
)
except Exception as e_upd:
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
return
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None
if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
should_send_lknpd_receipt = bool(
lknpd_service
and lknpd_service.configured
and payment_info_from_webhook.get("paid") is True
and payment_info_from_webhook.get("status") == "succeeded"
and payment_before_update
and payment_before_update.status != "succeeded"
)
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if (
settings.yookassa_autopayments_active
and isinstance(payment_method, dict)
and payment_method.get("saved", False)
):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get(
"account"
)
display_network = None
display_last4 = None
# Build generic display for various instrument types
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
# Wallets, SBP, etc. — use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm_id,
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm_id,
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
except Exception:
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook,
)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details["end_date"]
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
referral_bonus_info = None
if sale_mode_base == "subscription":
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days"
)
# Use user's DB language for all user-facing messages
user_lang = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
traffic_label = (
str(int(traffic_amount_gb))
if float(traffic_amount_gb).is_integer()
else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
)
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
amount=payment_value,
quantity=1.0,
operation_time=datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to send LKNPD receipt for payment %s",
yk_payment_id_from_hook,
)
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
# For auto-renew charges, avoid re-sending config link; send concise message
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
details_markup = None
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
if final_end_date_for_user
else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
else:
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
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:
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",
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
details_message,
reply_markup=details_markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
tariff_for_log = None
if payment_before_update and getattr(payment_before_update, "tariff_key", None):
tariff_for_log = payment_before_update.tariff_key
elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
tariff_for_log = updated_payment_record.tariff_key
elif payment_record and getattr(payment_record, "tariff_key", None):
tariff_for_log = payment_record.tariff_key
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
exc_info=True,
)
raise
async def process_cancelled_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
payment_db_id_str = metadata.get("payment_db_id")
if not user_id_str or not payment_db_id_str:
logging.warning(
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
)
return
try:
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
return
try:
updated_payment = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "canceled"),
yk_payment_id=payment_info_from_webhook.get("id"),
)
if updated_payment:
logging.info(
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
)
else:
logging.warning(
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
)
db_user = await user_dal.get_user_by_id(session, user_id)
user_lang = settings.DEFAULT_LANGUAGE
if db_user and db_user.language_code:
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
except Exception as e_process_cancel:
logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
exc_info=True,
)
raise
async def yookassa_webhook_route(request: web.Request):
try:
bot: Bot = request.app["bot"]
i18n_instance: JsonI18n = request.app["i18n"]
settings: Settings = request.app["settings"]
panel_service: PanelApiService = request.app["panel_service"]
subscription_service: SubscriptionService = request.app["subscription_service"]
referral_service: ReferralService = request.app["referral_service"]
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
except KeyError:
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
return web.Response(status=500, text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
logging.warning("YooKassa webhook denied from unauthorized IP source.")
return web.Response(status=403)
try:
event_json = await request.json()
notification_object = WebhookNotification(event_json)
payment_data_from_notification = notification_object.object
logging.info(
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
)
if (
not payment_data_from_notification
or not hasattr(payment_data_from_notification, "metadata")
or payment_data_from_notification.metadata is None
):
logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, "card", None)
pm_dict = {
"id": getattr(pm_obj, "id", None),
"type": getattr(pm_obj, "type", None),
"saved": bool(getattr(pm_obj, "saved", False)),
"title": getattr(pm_obj, "title", None),
"account_number": (
getattr(pm_obj, "account_number", None)
if hasattr(pm_obj, "account_number")
else (
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
)
),
"card": (
{
"first6": getattr(card_obj, "first6", None),
"last4": getattr(card_obj, "last4", None),
"expiry_month": getattr(card_obj, "expiry_month", None),
"expiry_year": getattr(card_obj, "expiry_year", None),
"card_type": getattr(card_obj, "card_type", None),
}
if card_obj is not None
else None
),
}
except Exception:
logging.exception("Failed to serialize YooKassa payment_method from webhook")
pm_dict = None
payment_dict_for_processing = {
"id": str(payment_data_from_notification.id),
"status": str(payment_data_from_notification.status),
"paid": bool(payment_data_from_notification.paid),
"amount": {
"value": str(payment_data_from_notification.amount.value),
"currency": str(payment_data_from_notification.amount.currency),
}
if payment_data_from_notification.amount
else {},
"metadata": dict(payment_data_from_notification.metadata),
"description": str(payment_data_from_notification.description)
if payment_data_from_notification.description
else None,
"payment_method": pm_dict,
}
if notification_object.event in {
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
YOOKASSA_EVENT_PAYMENT_CANCELED,
}:
queued = await enqueue_webhook_event(
settings,
"yookassa",
{
"event": notification_object.event,
"payment": payment_dict_for_processing,
},
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
)
if queued:
return web.Response(status=200, text="queued")
async with payment_processing_lock:
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if (
payment_dict_for_processing.get("paid")
and payment_dict_for_processing.get("status") == "succeeded"
):
await process_successful_payment(
session,
bot,
payment_dict_for_processing,
i18n_instance,
settings,
panel_service,
subscription_service,
referral_service,
lknpd_service,
)
await session.commit()
else:
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
f"paid='{payment_dict_for_processing.get('paid')}'"
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
session, bot, payment_dict_for_processing, i18n_instance, settings
)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if (
settings.yookassa_autopayments_active
and metadata.get("bind_only") == "1"
):
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get(
"payment_method"
)
if isinstance(payment_method, dict) and payment_method.get(
"id"
):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get(
"account_number"
) or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = (
card.get("card_type") or title or "Card"
)
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {
"yoo_money",
"yoomoney",
"yoo-money",
"wallet",
}:
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
display_network = "YooMoney"
if (
isinstance(account_number, str)
and len(account_number) >= 4
):
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (
pm_type.upper() if pm_type else "Payment method"
)
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(
session, user_id
)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(
i18n_lang, key, **kwargs
)
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_methods_keyboard,
)
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get(
"yookassa_service"
)
if yk:
await yk.cancel_payment(
payment_dict_for_processing.get("id")
)
except Exception:
logging.exception(
"Failed to cancel bind-only payment auth"
)
except Exception:
logging.exception(
"Failed to handle bind-only waiting_for_capture webhook"
)
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
notification_object.event,
payment_dict_for_processing.get("id"),
)
return web.Response(status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json")
except Exception:
logging.exception("YooKassa Webhook general processing error.")
return web.Response(status=500, text="internal_error")
+220
View File
@@ -0,0 +1,220 @@
import logging
import re
from datetime import datetime
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.states.user_states import UserPromoStates
from bot.utils.callback_answer import safe_answer_callback
from config.settings import Settings
from .start import send_main_menu
router = Router(name="user_promo_router")
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
re.IGNORECASE,
)
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await safe_answer_callback(callback, "Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error("CallbackQuery has no message in prompt_promo_code_input")
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
show_alert=True,
)
return
try:
await callback.message.edit_text(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
),
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for promo prompt: {e_edit}. Sending new one.")
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(
current_lang,
i18n,
callback_data=back_callback,
),
)
await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
f"FSM state: {await state.get_state()}"
)
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
async def process_promo_code_input(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot,
session: AsyncSession,
):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" # noqa: E501
)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_code_service:
logging.error("Dependencies (i18n or PromoCodeService) missing in process_promo_code_input")
await message.reply("Service error. Please try again later.")
await state.clear()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
code_input = message.text.strip() if message.text else ""
user = message.from_user
is_suspicious = False
if not code_input:
is_suspicious = True
logging.warning(f"Empty promo code input by user {user.id}.")
elif (
len(code_input) > MAX_PROMO_CODE_INPUT_LENGTH
or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(code_input)
or SUSPICIOUS_CHARS_REGEX.search(code_input)
):
is_suspicious = True
logging.warning(
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" # noqa: E501
)
response_to_user_text = ""
if is_suspicious:
# Send notification through NotificationService if enabled
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input,
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang
)
if success:
await session.commit()
logging.info(f"Promo code '{code_input}' successfully applied for user {user.id}.")
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link_display = active.get("config_link") if active else None
connect_button_url = active.get("connect_button_url") if active else None
config_link_text = config_link_display or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link_text,
)
reply_markup = get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
)
else:
await session.commit()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
await message.answer(
response_to_user_text,
reply_markup=reply_markup,
parse_mode="HTML",
)
await state.clear()
logging.info(
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." # noqa: E501
)
@router.callback_query(F.data == "main_action:back_to_main", UserPromoStates.waiting_for_promo_code)
async def cancel_promo_input_via_button(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button")
await safe_answer_callback(callback, "Language error", show_alert=True)
return
logging.info(
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." # noqa: E501
)
await state.clear()
if callback.message:
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await safe_answer_callback(
callback,
_("promo_input_cancelled_short"),
show_alert=False,
)
+254
View File
@@ -0,0 +1,254 @@
import logging
from typing import Optional, Union
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot, F, Router, types
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from config.settings import Settings
from db.dal import user_dal
router = Router(name="user_referral_router")
async def referral_command_handler(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
back_callback: str = "main_action:back_to_main",
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
logging.error(
"Target message is None in referral_command_handler (possibly from callback without message)." # noqa: E501
)
if isinstance(event, types.CallbackQuery):
await event.answer("Error displaying referral info.", show_alert=True)
return
if not i18n or not referral_service:
logging.error("Dependencies (i18n or ReferralService) missing in referral_command_handler")
await target_message_obj.answer("Service error. Please try again later.")
if isinstance(event, types.CallbackQuery):
await event.answer()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
except Exception as e_bot_info:
logging.error(f"Failed to get bot info for referral link: {e_bot_info}")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
if not bot_username:
logging.error("Bot username is None, cannot generate referral link.")
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
inviter_user_id = event.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s (probably missing DB record).",
inviter_user_id,
)
await target_message_obj.answer(_("error_generating_referral_link"))
if isinstance(event, types.CallbackQuery):
await event.answer()
return
bonus_info_parts = []
if getattr(settings, "traffic_sale_mode", False):
bonus_details_str = _("referral_not_available_for_traffic")
else:
if settings.subscription_options:
for months_period_key, _price in sorted(settings.subscription_options.items()):
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_(
"referral_bonus_per_period",
months=months_period_key,
inviter_bonus_days=inv_bonus
if inv_bonus is not None
else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
if ref_bonus is not None
else _("no_bonus_placeholder"),
)
)
bonus_details_str = (
"\n".join(bonus_info_parts) if bonus_info_parts else _("referral_no_bonuses_configured")
)
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
webapp_link_section = (
_(
"referral_webapp_link_line",
webapp_referral_link=webapp_referral_link,
)
if webapp_referral_link
else ""
)
text = _(
"referral_program_info_new",
referral_link=referral_link,
webapp_link_section=webapp_link_section,
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"],
)
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(
current_lang,
i18n,
back_callback=back_callback,
)
if isinstance(event, types.Message):
await event.answer(text, reply_markup=reply_markup_val, disable_web_page_preview=True)
elif isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
except Exception as e_edit:
logging.warning(f"Failed to edit message for referral info: {e_edit}. Sending new one.")
await event.message.answer(
text, reply_markup=reply_markup_val, disable_web_page_preview=True
)
await event.answer()
@router.callback_query(F.data.startswith("referral_action:"))
async def referral_action_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
action = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message":
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
inviter_user_id = callback.from_user.id
referral_link = await referral_service.generate_referral_link(
session, bot_username, inviter_user_id
)
if not referral_link:
logging.error(
"Failed to generate referral link for user %s via inline button.",
inviter_user_id,
)
await callback.answer(_("error_generating_referral_link"), show_alert=True)
return
webapp_referral_link = await _generate_webapp_referral_link(
session,
settings,
inviter_user_id,
)
if webapp_referral_link:
friend_message = _(
"referral_friend_message_with_webapp",
referral_link=referral_link,
webapp_referral_link=webapp_referral_link,
)
else:
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(friend_message, disable_web_page_preview=True)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
await callback.answer(_("error_occurred_try_again"), show_alert=True)
await callback.answer()
def _build_webapp_referral_link(
base_url: Optional[str], referral_code: Optional[str]
) -> Optional[str]:
if not base_url or not referral_code:
return None
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["ref"] = f"u{referral_code}"
return urlunsplit(
(
parts.scheme,
parts.netloc,
parts.path or "/",
urlencode(query),
parts.fragment,
)
)
async def _generate_webapp_referral_link(
session: AsyncSession,
settings: Settings,
inviter_user_id: int,
) -> Optional[str]:
if not settings.SUBSCRIPTION_MINI_APP_URL:
return None
db_user = await user_dal.get_user_by_id(session, inviter_user_id)
referral_code = await user_dal.ensure_referral_code(session, db_user) if db_user else None
return _build_webapp_referral_link(
settings.SUBSCRIPTION_MINI_APP_URL,
referral_code,
)
@router.message(Command("referral"))
async def referral_command_message_handler(
message: types.Message,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession,
):
await referral_command_handler(message, settings, i18n_data, referral_service, bot, session)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
from aiogram import Router
from . import core, payment_methods, payments
router = Router(name="user_subscription_router")
# Include sub-routers
router.include_router(core.router)
router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import ( # noqa: E402,F401
display_subscription_options,
my_devices_command_handler,
my_subscription_command_handler,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,550 @@
from typing import List, Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from bot.keyboards.inline.user_keyboards import (
get_bind_url_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_payment_methods_list_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import user_billing_dal
from db.models import Payment
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(
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")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
description="Bind card",
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=True,
capture=False,
bind_only=True,
)
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(
_("payment_methods_title"),
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(
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 settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(
_("payment_method_delete_confirm"),
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(
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")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id_raw = parts[2] if len(parts) >= 3 else ""
deleted = False
try:
from db.dal.user_billing_dal import (
delete_user_payment_method,
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(
session, callback.from_user.id, int(pm_id_raw)
)
else:
deleted = await delete_user_payment_method_by_provider_id(
session, callback.from_user.id, pm_id_raw
)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
session, callback.from_user.id
)
deleted = deleted or legacy_deleted
except Exception:
pass
await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id)
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(
f"{msg}\n\n{text}",
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(
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")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next(
(
m
for m in methods
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
),
methods[0],
)
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime("%Y-%m-%d")
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
str(sel.method_id), current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
return
added_at = (
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else ""
)
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
except Exception:
pass
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
billing.yookassa_payment_method_id, current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
selected_pm_provider_id: Optional[str] = None
pm_filter_requested: bool = False
try:
split_a, split_b, split_pm_id = callback.data.split(":", 2)
if split_pm_id:
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
selected_pm_provider_id = sel.provider_payment_method_id
else:
selected_pm_provider_id = split_pm_id
except Exception:
selected_pm_provider_id = None
pm_filter_requested = False
if pm_filter_requested and not selected_pm_provider_id:
user_payments = []
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != "yookassa":
continue
if p.yookassa_payment_id and yookassa_service:
try:
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
pm = (info or {}).get("payment_method") or {}
if pm.get("id") == selected_pm_provider_id:
filtered.append(p)
continue
except Exception:
pass
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
except Exception:
back_pm_id = ""
back_markup = (
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
if back_pm_id
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
traffic_mode = getattr(settings, "traffic_sale_mode", False)
def _format_item(p: Payment) -> str:
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
title = p.description or _(
"subscription_purchase_title", months=p.subscription_duration_months or 1
)
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
try:
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(text, reply_markup=back_markup)
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(
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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
try:
_, _, page_str = callback.data.split(":", 2)
page = int(page_str)
except Exception:
page = 0
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@@ -0,0 +1,21 @@
from aiogram import Router
from .payments_crypto import router as crypto_router
from .payments_freekassa import router as freekassa_router
from .payments_platega import router as platega_router
from .payments_severpay import router as severpay_router
from .payments_stars import router as stars_router
from .payments_subscription import router as subscription_selection_router
from .payments_yookassa import router as yookassa_router
router = Router(name="user_subscription_payments_router")
router.include_router(subscription_selection_router)
router.include_router(yookassa_router)
router.include_router(freekassa_router)
router.include_router(platega_router)
router.include_router(severpay_router)
router.include_router(crypto_router)
router.include_router(stars_router)
__all__ = ["router"]
@@ -0,0 +1,128 @@
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from config.settings import Settings
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_amount = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
)
if invoice_url:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,244 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.freekassa_service import FreeKassaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=months,
amount=price_rub,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
if success:
location = response_data.get("location")
order_hash = response_data.get("orderHash")
order_id_api = response_data.get("orderId")
provider_identifier = order_hash or order_id_api
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if location:
order_identifier_display = str(
order_id_api or provider_identifier or payment_record.payment_id
)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
else:
logging.error(
"FreeKassa: create_order failed for payment %s with response %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,261 @@
import json
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.platega_service import PlategaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
callback_prefix, _, _ = (callback.data or "").partition(":")
if callback_prefix == "pay_platega_crypto":
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
platega_variant = "crypto"
if not settings.PLATEGA_CRYPTO_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
elif callback_prefix == "pay_platega_sbp":
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
if not settings.PLATEGA_SBP_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
else:
# Legacy callback (pre-split): keep working as SBP
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
if success:
transaction_id = response_data.get("transactionId") or response_data.get("id")
redirect_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
)
provider_status = response_data.get("status", payment_record.status)
if transaction_id and redirect_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(transaction_id),
str(provider_status),
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Platega: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,221 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.severpay_service import SeverPayService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
)
if success:
payment_link = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
)
provider_identifier = response_data.get("id") or response_data.get("uid")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -0,0 +1,148 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.stars_service import StarsService
from config.settings import Settings
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
stars_price = int(float(parts[1]))
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=user_id,
months=months,
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
)
if payment_db_id:
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)
]
]
),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
@@ -0,0 +1,113 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
router = Router(name="user_subscription_payments_selection_router")
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(
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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
traffic_packages = getattr(settings, "traffic_packages", {}) or {}
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
try:
months = float(callback.data.split(":")[-1])
except (ValueError, IndexError):
logging.error(f"Invalid subscription period in callback_data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_source = traffic_packages if traffic_mode else settings.subscription_options
stars_price_source = (
stars_traffic_packages if traffic_mode else settings.stars_subscription_options
)
price_rub = price_source.get(months)
stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
if price_rub is None:
if traffic_mode and not price_source and stars_price is not None:
currency_methods_enabled = any(
[
settings.FREEKASSA_ENABLED,
settings.PLATEGA_ENABLED,
settings.SEVERPAY_ENABLED,
settings.YOOKASSA_ENABLED,
settings.CRYPTOPAY_ENABLED,
]
)
if currency_methods_enabled:
logging.error(
"Currency price missing for traffic option %s while fiat providers are enabled.", # noqa: E501
months,
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
price_rub = 0.0
currency_symbol_val = ""
else:
logging.error(
f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." # noqa: E501
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
text_content = (
get_text("choose_payment_method_traffic")
if traffic_mode
else get_text("choose_payment_method")
)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
stars_price,
currency_symbol_val,
current_lang,
i18n,
settings,
sale_mode="traffic" if traffic_mode else "subscription",
)
try:
await callback.message.edit_text(text_content, reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(text_content, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@@ -0,0 +1,842 @@
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_payment_url_keyboard,
get_yk_autopay_choice_keyboard,
get_yk_saved_cards_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal
router = Router(name="user_subscription_payments_yookassa_router")
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
try:
parts = payload.split(":")
value = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
return value, price, sale_mode
except (ValueError, IndexError):
return None
def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
if _is_yoomoney_network(network):
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
title = get_text("payment_method_wallet_title", last4=inferred_last4)
elif last4:
network_name = network or get_text("payment_network_card")
title = get_text("payment_method_card_title", network=network_name, last4=last4)
else:
network_name = network or get_text("payment_network_generic")
title = get_text("payment_method_generic_title", network=network_name)
return f"{title}" if is_default else title
async def _initiate_yk_payment(
callback: types.CallbackQuery,
*,
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
user_id: int,
months: int,
price_rub: float,
currency_code_for_yk: str,
save_payment_method: bool,
back_callback: str,
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
return False
sale_base = _sale_mode_base(sale_mode)
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
if not db_payment_record:
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode,
}
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk,
save_payment_method=save_payment_method,
payment_method_id=payment_method_id,
)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
pm = payment_response_yk.get("payment_method")
try:
if pm and pm.get("id"):
pm_type = pm.get("type")
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = (
account_number[-4:]
if isinstance(account_number, str) and len(account_number) >= 4
else None
)
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm["id"],
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm["id"],
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=save_payment_method,
)
except Exception:
pass
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to save YooKassa payment method preliminarily")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after initiating payment"
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception:
pass
return False
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
return True
if payment_response_yk and payment_method_id:
status_to_store = payment_response_yk.get("status", "pending")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=status_to_store,
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after saved-card payment start"
)
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
logging.error(
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
message_text = get_text("yookassa_autopay_charge_initiated")
try:
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception as e_edit:
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
try:
await callback.message.answer(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception:
pass
return True
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation"
)
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
exc_info=True,
)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
saved_methods: List = []
if autopay_enabled:
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if autopay_enabled and saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
try:
await callback.message.answer(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if not saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
cards: List[Tuple[str, str]] = []
for method in saved_methods:
title = _format_saved_payment_method_title(
get_text, method.card_network, method.card_last4, method.is_default
)
cards.append((str(method.method_id), title))
per_page = 5
max_page = max(0, (len(cards) - 1) // per_page)
page = max(0, min(page, max_page))
try:
await callback.message.edit_text(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display saved card list: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 3:
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
method_identifier = parts[2]
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
selected_method = None
for method in saved_methods:
if method_identifier.isdigit():
if method.method_id == int(method_identifier):
selected_method = method
break
if method.provider_payment_method_id == method_identifier:
selected_method = method
break
if not selected_method:
logging.warning(
f"Selected payment method not found for user {user_id}: {method_identifier}"
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_code_for_yk = "RUB"
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=False,
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
+315
View File
@@ -0,0 +1,315 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_connect_and_main_keyboard,
get_main_menu_inline_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from config.settings import Settings
from .start import send_main_menu
router = Router(name="user_trial_router")
async def request_trial_confirmation_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
user_id = callback.from_user.id
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:
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(session, user_id):
pass
if not settings.TRIAL_ENABLED:
await callback.message.edit_text(
_("trial_feature_disabled"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
except Exception:
pass
return
if await subscription_service.has_had_any_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False),
)
try:
await callback.answer()
except Exception:
pass
return
# Directly activate trial without confirmation
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
if activation_result and activation_result.get("activated"):
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
else _("traffic_unlimited")
)
final_message_text_in_chat = _(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
if activation_result
else "trial_activation_failed"
)
final_message_text_in_chat = _(message_key_from_service)
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
reply_markup = (
get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
)
)
try:
await callback.message.edit_text(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
@router.callback_query(F.data == "trial_action:confirm_activate")
async def confirm_activate_trial_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
session: AsyncSession,
):
user_id = callback.from_user.id
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:
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.TRIAL_ENABLED:
try:
await callback.answer(_("trial_feature_disabled"), show_alert=True)
except Exception:
pass
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
if await subscription_service.has_had_any_subscription(session, user_id):
try:
await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True)
except Exception:
pass
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_display_for_trial = None
config_link_for_trial = None
connect_button_url_for_trial = None
if activation_result and activation_result.get("activated"):
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
end_date_obj = activation_result.get("end_date")
config_link_display_for_trial, connect_button_url_for_trial = await prepare_config_links(
settings, activation_result.get("subscription_url")
)
config_link_for_trial = config_link_display_for_trial or _("config_link_not_available")
traffic_gb_val = activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
else _("traffic_unlimited")
)
final_message_text_in_chat = _(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d") if isinstance(end_date_obj, datetime) else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
if activation_result
else "trial_activation_failed"
)
final_message_text_in_chat = _(message_key_from_service)
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
session, user_id
):
show_trial_button_after_action = True
reply_markup = (
get_connect_and_main_keyboard(
current_lang,
i18n,
settings,
config_link_display_for_trial,
connect_button_url=connect_button_url_for_trial,
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
)
)
try:
await callback.message.edit_text(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(f"Could not edit trial result message: {e_edit}. Sending new one.")
if callback.message:
await callback.message.answer(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
@router.callback_query(F.data == "main_action:cancel_trial")
async def cancel_trial_activation(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
await send_main_menu(callback, settings, i18n_data, subscription_service, session, is_edit=True)