Add Docker build workflow and enhance admin functionalities
- Introduced a new GitHub Actions workflow for building and pushing the development Docker image. - Added inline mode handling for user interactions, allowing users to share referral links and view statistics. - Enhanced admin functionalities with new sections for user management, statistics, and promo code management. - Implemented CSV export for logs and improved notification services for various events, including new user registrations and payment notifications. - Updated localization files to support new features and commands.
This commit is contained in:
@@ -6,7 +6,11 @@ from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_admin_panel_keyboard, get_stats_monitoring_keyboard,
|
||||
get_user_management_keyboard, get_ban_management_keyboard,
|
||||
get_promo_marketing_keyboard, get_system_functions_keyboard
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
@@ -75,6 +79,9 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "create_promo":
|
||||
await admin_promo_handlers.create_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "create_bulk_promo":
|
||||
await admin_promo_handlers.create_bulk_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "manage_promos":
|
||||
await admin_promo_handlers.manage_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
@@ -87,6 +94,10 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "unban_user_prompt":
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
await admin_user_mgmnt_handlers.view_banned_users_handler(
|
||||
@@ -121,3 +132,58 @@ async def admin_panel_actions_callback_handler(
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, List, Dict, Any
|
||||
@@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
|
||||
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",
|
||||
default="🔄 Начинаю экспорт логов в CSV..."
|
||||
))
|
||||
|
||||
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",
|
||||
default="❌ Нет данных для экспорта"
|
||||
))
|
||||
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", default="Log ID"),
|
||||
_("admin_csv_header_timestamp", default="Timestamp"),
|
||||
_("admin_csv_header_user_id", default="User ID"),
|
||||
_("admin_csv_header_telegram_username", default="Telegram Username"),
|
||||
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
|
||||
_("admin_csv_header_event_type", default="Event Type"),
|
||||
_("admin_csv_header_content", default="Content"),
|
||||
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
|
||||
_("admin_csv_header_target_user_id", default="Target User ID"),
|
||||
_("admin_csv_header_raw_update_preview", default="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",
|
||||
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
|
||||
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",
|
||||
default="❌ Ошибка при экспорте логов: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -340,6 +342,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_details,
|
||||
AdminStates.waiting_for_promo_edit_details,
|
||||
AdminStates.waiting_for_bulk_promo_details,
|
||||
),
|
||||
)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
@@ -365,3 +368,219 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
|
||||
await callback.answer(_("admin_action_cancelled_default_alert"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_bulk_promo_create_prompt",
|
||||
default="📦 Массовое создание промокодов\n\nВведите данные в формате:\n<количество> <бонусные_дни> <максимальное_использование> [дни_действия]\n\nПример: 50 7 100 30\n(создаст 50 промокодов на 7 дней с лимитом 100 активаций каждый, действующих 30 дней)",
|
||||
example_format="50 7 100 30"
|
||||
)
|
||||
|
||||
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 bulk promo 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_bulk_promo_details)
|
||||
|
||||
|
||||
def generate_unique_promo_code(length: int = 8) -> str:
|
||||
"""Generate a unique promotional code"""
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
# Exclude confusing characters
|
||||
characters = characters.replace('0', '').replace('O', '').replace('1', '').replace('I', '').replace('L', '')
|
||||
return ''.join(random.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_bulk_promo_details, F.text)
|
||||
async def process_bulk_promo_details_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)
|
||||
|
||||
if not message.text:
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
|
||||
parts = message.text.strip().split()
|
||||
if not (3 <= len(parts) <= 4):
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_format",
|
||||
default="❌ Неверный формат. Используйте: <количество> <бонусные_дни> <максимальное_использование> [дни_действия]"
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
count = int(parts[0])
|
||||
bonus_days = int(parts[1])
|
||||
max_activations = int(parts[2])
|
||||
|
||||
if count <= 0 or count > 1000:
|
||||
raise ValueError(_("admin_bulk_promo_invalid_count", default="Количество должно быть от 1 до 1000"))
|
||||
|
||||
if bonus_days <= 0 or max_activations <= 0:
|
||||
raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
|
||||
|
||||
valid_until_date: Optional[datetime] = None
|
||||
valid_until_str_display = _("admin_promo_valid_indefinitely")
|
||||
|
||||
if len(parts) == 4:
|
||||
valid_days_from_now = int(parts[3])
|
||||
if valid_days_from_now <= 0:
|
||||
raise ValueError(_("admin_promo_invalid_validity_days"))
|
||||
valid_until_date = datetime.now(
|
||||
timezone.utc) + timedelta(days=valid_days_from_now)
|
||||
valid_until_str_display = _(
|
||||
"admin_promo_valid_until_display",
|
||||
date=valid_until_date.strftime('%Y-%m-%d'))
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_invalid_values",
|
||||
default="❌ Неверные значения: {error}",
|
||||
error=str(e)
|
||||
))
|
||||
return
|
||||
except Exception as e_parse:
|
||||
logging.error(f"Error parsing bulk promo details '{message.text}': {e_parse}")
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
admin_id = message.from_user.id if message.from_user else 0
|
||||
|
||||
# Generate unique codes and create promo codes
|
||||
created_codes = []
|
||||
failed_codes = []
|
||||
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_creating",
|
||||
default="🔄 Создаю {count} промокодов...",
|
||||
count=count
|
||||
))
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
# Generate unique code
|
||||
attempts = 0
|
||||
while attempts < 10: # Max 10 attempts to generate unique code
|
||||
code = generate_unique_promo_code()
|
||||
|
||||
# Check if code already exists
|
||||
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code)
|
||||
if not existing_promo:
|
||||
break
|
||||
attempts += 1
|
||||
|
||||
if attempts >= 10:
|
||||
failed_codes.append(f"Failed to generate unique code #{i+1}")
|
||||
continue
|
||||
|
||||
promo_data_to_create = {
|
||||
"code": code,
|
||||
"bonus_days": bonus_days,
|
||||
"max_activations": max_activations,
|
||||
"created_by_admin_id": admin_id,
|
||||
"valid_until": valid_until_date,
|
||||
"is_active": True,
|
||||
"current_activations": 0
|
||||
}
|
||||
|
||||
created_promo = await promo_code_dal.create_promo_code(
|
||||
session, promo_data_to_create)
|
||||
|
||||
if created_promo:
|
||||
created_codes.append(created_promo.code)
|
||||
else:
|
||||
failed_codes.append(f"Failed to create code #{i+1}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating bulk promo code #{i+1}: {e}")
|
||||
failed_codes.append(f"Error creating code #{i+1}: {str(e)}")
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
|
||||
# Prepare success message
|
||||
success_text_parts = [
|
||||
_(
|
||||
"admin_bulk_promo_created_success",
|
||||
default="✅ Массовое создание завершено!\n\n📦 Создано промокодов: {created_count}\n💎 Бонусных дней: {bonus_days}\n🔄 Макс. активаций каждого: {max_activations}\n⏰ Действительны до: {valid_until}",
|
||||
created_count=len(created_codes),
|
||||
bonus_days=bonus_days,
|
||||
max_activations=max_activations,
|
||||
valid_until=valid_until_str_display
|
||||
)
|
||||
]
|
||||
|
||||
if failed_codes:
|
||||
success_text_parts.append(f"\n❌ Ошибок: {len(failed_codes)}")
|
||||
|
||||
if created_codes:
|
||||
# Show first few codes as examples
|
||||
codes_to_show = created_codes[:10] # Show first 10
|
||||
success_text_parts.append(f"\n📝 Примеры созданных кодов:")
|
||||
success_text_parts.append("\n".join([f"• {code}" for code in codes_to_show]))
|
||||
|
||||
if len(created_codes) > 10:
|
||||
success_text_parts.append(f"... и еще {len(created_codes) - 10} кодов")
|
||||
|
||||
# Send codes as a file if many were created
|
||||
if len(created_codes) > 20:
|
||||
try:
|
||||
codes_text = "\n".join(created_codes)
|
||||
codes_file = types.BufferedInputFile(
|
||||
codes_text.encode('utf-8'),
|
||||
filename=f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
)
|
||||
await message.answer_document(
|
||||
codes_file,
|
||||
caption=_(
|
||||
"admin_bulk_promo_codes_file",
|
||||
default="📄 Файл со всеми созданными промокодами"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send codes file: {e}")
|
||||
|
||||
final_text = "\n".join(success_text_parts)
|
||||
await message.answer(
|
||||
final_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
|
||||
except Exception as e_db_commit:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to commit bulk promo codes creation: {e_db_commit}", exc_info=True)
|
||||
await message.answer(_(
|
||||
"admin_bulk_promo_creation_failed",
|
||||
default="❌ Ошибка при сохранении промокодов в базу данных"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,13 +29,52 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
|
||||
|
||||
user_stats_dict = await user_dal.get_user_count_stats_dal(session)
|
||||
# Enhanced user statistics
|
||||
user_stats = await user_dal.get_enhanced_user_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
_("admin_stats_users",
|
||||
total_users=user_stats_dict.get("total_users", 0),
|
||||
banned_users=user_stats_dict.get("banned_users", 0),
|
||||
active_subs=user_stats_dict.get("users_with_active_subscriptions",
|
||||
0)))
|
||||
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📊 Всего: <b>{user_stats['total_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📈 Активных сегодня: <b>{user_stats['active_today']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"💳 С платной подпиской: <b>{user_stats['paid_subscriptions']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🆓 На пробном периоде: <b>{user_stats['trial_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"😴 Неактивных: <b>{user_stats['inactive_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🚫 Заблокированных: <b>{user_stats['banned_users']}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🎁 Привлечено по реферальной программе: <b>{user_stats['referral_users']}</b>"
|
||||
)
|
||||
|
||||
# Financial statistics
|
||||
financial_stats = await payment_dal.get_financial_statistics(session)
|
||||
|
||||
stats_text_parts.append(
|
||||
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 За сегодня: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} платежей)"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 За неделю: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"📅 За месяц: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f"🏆 За все время: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
|
||||
)
|
||||
|
||||
last_payments_models: List[
|
||||
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,351 @@
|
||||
import logging
|
||||
from aiogram import Router, types, Bot
|
||||
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal, payment_dal
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
results.extend(stats_results)
|
||||
|
||||
# Show help if no specific query
|
||||
if not query:
|
||||
help_result = await create_help_result(i18n, current_lang, is_admin)
|
||||
results.append(help_result)
|
||||
|
||||
# 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) -> 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 = referral_service.generate_referral_link(bot_username, user_id)
|
||||
|
||||
# Create message content
|
||||
message_text = _(
|
||||
"inline_referral_message",
|
||||
default="🚀 Привет! Попробуй этот крутой VPN сервис!\n\n"
|
||||
"✨ Быстрый и надежный\n"
|
||||
"🔒 Полная анонимность\n"
|
||||
"🌍 Серверы по всему миру\n"
|
||||
"💎 Бесплатный пробный период\n\n"
|
||||
"Переходи по ссылке: {referral_link}",
|
||||
referral_link=referral_link
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="referral_link",
|
||||
title=_(
|
||||
"inline_referral_title",
|
||||
default="🎁 Пригласить друга"
|
||||
),
|
||||
description=_(
|
||||
"inline_referral_description",
|
||||
default="Поделиться реферальной ссылкой для получения бонусов"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=message_text,
|
||||
disable_web_page_preview=True
|
||||
),
|
||||
thumbnail_url="https://cdn-icons-png.flaticon.com/512/1077/1077114.png"
|
||||
)
|
||||
|
||||
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) -> 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)
|
||||
if user_stats_result:
|
||||
results.append(user_stats_result)
|
||||
|
||||
# Quick financial stats
|
||||
financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang)
|
||||
if financial_stats_result:
|
||||
results.append(financial_stats_result)
|
||||
|
||||
# Quick system stats
|
||||
system_stats_result = await create_system_stats_result(session, i18n_instance, lang)
|
||||
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) -> 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",
|
||||
default="👥 <b>Статистика пользователей</b>\n\n"
|
||||
"📊 Всего: <b>{total}</b>\n"
|
||||
"📈 Активных сегодня: <b>{active_today}</b>\n"
|
||||
"💳 С платной подпиской: <b>{paid}</b>\n"
|
||||
"🆓 На пробном периоде: <b>{trial}</b>\n"
|
||||
"😴 Неактивных: <b>{inactive}</b>\n"
|
||||
"🚫 Заблокированных: <b>{banned}</b>\n"
|
||||
"🎁 По реферальной программе: <b>{referral}</b>",
|
||||
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",
|
||||
default="👥 Статистика пользователей"
|
||||
),
|
||||
description=_(
|
||||
"inline_admin_user_stats_desc",
|
||||
default=f"Всего: {user_stats['total_users']}, Активных: {user_stats['paid_subscriptions']}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url="https://cdn-icons-png.flaticon.com/512/681/681494.png"
|
||||
)
|
||||
|
||||
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) -> 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",
|
||||
default="💰 <b>Финансовая статистика</b>\n\n"
|
||||
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
|
||||
" ({today_count} платежей)\n"
|
||||
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
|
||||
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
|
||||
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
|
||||
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",
|
||||
default="💰 Финансовая статистика"
|
||||
),
|
||||
description=_(
|
||||
"inline_admin_financial_stats_desc",
|
||||
default=f"Сегодня: {financial_stats['today_revenue']:.2f} RUB"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url="https://cdn-icons-png.flaticon.com/512/2769/2769339.png"
|
||||
)
|
||||
|
||||
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) -> Optional[InlineQueryResultArticle]:
|
||||
"""Create system statistics result with online/offline/expired/limited info"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select, func, and_
|
||||
from db.models import User, Subscription
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Count active subscriptions (online)
|
||||
active_subs_stmt = select(func.count(Subscription.subscription_id)).where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now
|
||||
)
|
||||
)
|
||||
active_subs = (await session.execute(active_subs_stmt)).scalar() or 0
|
||||
|
||||
# Count expired subscriptions
|
||||
expired_subs_stmt = select(func.count(Subscription.subscription_id)).where(
|
||||
and_(
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date <= now
|
||||
)
|
||||
)
|
||||
expired_subs = (await session.execute(expired_subs_stmt)).scalar() or 0
|
||||
|
||||
# Count total users (approximation for "total")
|
||||
total_users_stmt = select(func.count(User.user_id))
|
||||
total_users = (await session.execute(total_users_stmt)).scalar() or 0
|
||||
|
||||
# Offline = users without active subscriptions
|
||||
offline_users = total_users - active_subs
|
||||
|
||||
stats_text = _(
|
||||
"inline_system_stats_message",
|
||||
default="🖥 <b>Системная статистика</b>\n\n"
|
||||
"🟢 Онлайн: <b>{online}</b>\n"
|
||||
"🔴 Офлайн: <b>{offline}</b>\n"
|
||||
"⏰ Истекшие: <b>{expired}</b>\n"
|
||||
"👥 Всего пользователей: <b>{total}</b>",
|
||||
online=active_subs,
|
||||
offline=max(0, offline_users),
|
||||
expired=expired_subs,
|
||||
total=total_users
|
||||
)
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="admin_system_stats",
|
||||
title=_(
|
||||
"inline_admin_system_stats_title",
|
||||
default="🖥 Системная статистика"
|
||||
),
|
||||
description=_(
|
||||
"inline_admin_system_stats_desc",
|
||||
default=f"Онлайн: {active_subs}, Офлайн: {max(0, offline_users)}"
|
||||
),
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=stats_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url="https://cdn-icons-png.flaticon.com/512/2920/2920277.png"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error creating system stats result: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def create_help_result(i18n_instance, lang: str, is_admin: bool) -> InlineQueryResultArticle:
|
||||
"""Create help result explaining inline mode features"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
if is_admin:
|
||||
help_text = _(
|
||||
"inline_admin_help_message",
|
||||
default="🤖 <b>Inline режим бота</b>\n\n"
|
||||
"📱 <b>Доступные команды:</b>\n\n"
|
||||
"🎁 <b>реф/ref</b> - поделиться реферальной ссылкой\n"
|
||||
"👥 <b>стат/stat</b> - статистика пользователей\n"
|
||||
"💰 <b>финансы</b> - финансовая статистика\n"
|
||||
"🖥 <b>система</b> - системная статистика\n\n"
|
||||
"💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!"
|
||||
)
|
||||
title = _("inline_admin_help_title", default="🤖 Inline помощь (Админ)")
|
||||
description = _("inline_admin_help_desc", default="Доступны команды: реф, стат, финансы, система")
|
||||
else:
|
||||
help_text = _(
|
||||
"inline_user_help_message",
|
||||
default="🤖 <b>Inline режим бота</b>\n\n"
|
||||
"📱 <b>Доступные команды:</b>\n\n"
|
||||
"🎁 <b>реф/ref</b> - поделиться реферальной ссылкой\n\n"
|
||||
"💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!"
|
||||
)
|
||||
title = _("inline_user_help_title", default="🤖 Inline помощь")
|
||||
description = _("inline_user_help_desc", default="Доступна команда: реф (реферальная ссылка)")
|
||||
|
||||
return InlineQueryResultArticle(
|
||||
id="help",
|
||||
title=title,
|
||||
description=description,
|
||||
input_message_content=InputTextMessageContent(
|
||||
message_text=help_text,
|
||||
parse_mode="HTML"
|
||||
),
|
||||
thumbnail_url="https://cdn-icons-png.flaticon.com/512/906/906794.png"
|
||||
)
|
||||
@@ -20,7 +20,7 @@ from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import notify_admin_new_payment
|
||||
from bot.services.notification_service import notify_admin_new_payment, NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
@@ -195,6 +195,22 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
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)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=user_id,
|
||||
amount=payment_value,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
months=subscription_months,
|
||||
payment_provider="yookassa", # This is specifically for YooKassa webhook
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send payment notification: {e}")
|
||||
|
||||
# Legacy notification for backwards compatibility
|
||||
await notify_admin_new_payment(
|
||||
bot,
|
||||
settings,
|
||||
|
||||
@@ -87,7 +87,8 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
referral_link=referral_link,
|
||||
bonus_details=bonus_details_str)
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
|
||||
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(text,
|
||||
@@ -106,3 +107,37 @@ async def referral_command_handler(event: Union[types.Message,
|
||||
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("Ошибка получения имени бота", show_alert=True)
|
||||
return
|
||||
|
||||
inviter_user_id = callback.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
||||
|
||||
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("Произошла ошибка", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -129,6 +129,8 @@ async def start_command_handler(message: types.Message,
|
||||
user_id = user.id
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
promo_code_to_apply: Optional[str] = None
|
||||
|
||||
if command and command.args:
|
||||
arg_payload = command.args
|
||||
if arg_payload.startswith("ref_"):
|
||||
@@ -142,6 +144,14 @@ async def start_command_handler(message: types.Message,
|
||||
logging.warning(
|
||||
f"Could not parse referral from /start args '{arg_payload}': {e}"
|
||||
)
|
||||
elif arg_payload.startswith("promo_"):
|
||||
try:
|
||||
promo_code_to_apply = arg_payload.split("_")[1]
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
except (IndexError, ValueError) as e:
|
||||
logging.warning(
|
||||
f"Could not parse promo code from /start args '{arg_payload}': {e}"
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -160,6 +170,19 @@ async def start_command_handler(message: types.Message,
|
||||
logging.info(
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
|
||||
# Send notification about new user registration
|
||||
try:
|
||||
from bot.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_new_user_registration(
|
||||
user_id=user_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
referred_by_id=referred_by_user_id
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send new user notification: {e}")
|
||||
except Exception as e_create:
|
||||
|
||||
logging.error(
|
||||
@@ -194,6 +217,53 @@ async def start_command_handler(message: types.Message,
|
||||
exc_info=True)
|
||||
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
promo_code_service = PromoCodeService()
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
|
||||
from datetime import datetime
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
promo_success_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,
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
|
||||
@@ -59,21 +59,94 @@ async def request_trial_confirmation_handler(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic_gb_display = (
|
||||
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
|
||||
else _("traffic_unlimited")
|
||||
# Directly activate trial without confirmation
|
||||
activation_result = await subscription_service.activate_trial_subscription(
|
||||
session, user_id
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_(
|
||||
"trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display,
|
||||
),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
|
||||
if activation_result and activation_result.get("activated"):
|
||||
await callback.answer(_("trial_activated_alert"), show_alert=True)
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") 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
|
||||
await notify_admin_new_trial(
|
||||
callback.bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
end_date_obj,
|
||||
)
|
||||
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)
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
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
|
||||
and hasattr(callback.message, "chat")
|
||||
and callback.message.chat
|
||||
):
|
||||
await callback.message.chat.send_message(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||
|
||||
@@ -12,27 +12,105 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Статистика и мониторинг
|
||||
builder.button(text=_(key="admin_stats_and_monitoring_section"),
|
||||
callback_data="admin_section:stats_monitoring")
|
||||
|
||||
# Управление пользователями
|
||||
builder.button(text=_(key="admin_user_management_section"),
|
||||
callback_data="admin_section:user_management")
|
||||
|
||||
# Промокоды и маркетинг
|
||||
builder.button(text=_(key="admin_promo_marketing_section"),
|
||||
callback_data="admin_section:promo_marketing")
|
||||
|
||||
# Системные функции
|
||||
builder.button(text=_(key="admin_system_functions_section"),
|
||||
callback_data="admin_section:system_functions")
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_stats_button"),
|
||||
callback_data="admin_action:stats")
|
||||
builder.button(text=_(key="admin_broadcast_button"),
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_manage_promos_button"),
|
||||
callback_data="admin_action:manage_promos")
|
||||
builder.button(text=_(key="admin_view_promos_button"),
|
||||
callback_data="admin_action:view_promos")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_ban_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_ban_user_button"),
|
||||
callback_data="admin_action:ban_user_prompt")
|
||||
builder.button(text=_(key="admin_unban_user_button"),
|
||||
callback_data="admin_action:unban_user_prompt")
|
||||
builder.button(text=_(key="admin_view_banned_users_button"),
|
||||
callback_data="admin_action:view_banned:0")
|
||||
builder.button(text=_(key="admin_view_logs_menu_button"),
|
||||
callback_data="admin_action:view_logs_menu")
|
||||
|
||||
builder.button(text=_(key="back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management")
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_create_bulk_promo_button"),
|
||||
callback_data="admin_action:create_bulk_promo")
|
||||
builder.button(text=_(key="admin_manage_promos_button"),
|
||||
callback_data="admin_action:manage_promos")
|
||||
builder.button(text=_(key="admin_view_promos_button"),
|
||||
callback_data="admin_action:view_promos")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_broadcast_button"),
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_sync_panel_button"),
|
||||
callback_data="admin_action:sync_panel")
|
||||
builder.adjust(2, 2, 2, 2, 1)
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -43,9 +121,12 @@ def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
|
||||
callback_data="admin_logs:view_all:0")
|
||||
builder.button(text=_(key="admin_view_user_logs_prompt_button"),
|
||||
callback_data="admin_logs:prompt_user")
|
||||
builder.button(text=_(key="admin_export_logs_csv_button"),
|
||||
callback_data="admin_logs:export_csv")
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -158,8 +158,11 @@ def get_referral_link_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="referral_share_message_button"),
|
||||
callback_data="referral_action:share_message")
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
|
||||
from bot.handlers.user import user_router_aggregate
|
||||
from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.handlers import inline_mode
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
@@ -78,6 +79,9 @@ class DBSessionMiddleware(BaseMiddleware):
|
||||
|
||||
async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
dp.include_router(user_router_aggregate)
|
||||
|
||||
# Add inline mode router (available for all users)
|
||||
dp.include_router(inline_mode.router)
|
||||
|
||||
admin_main_router = Router(name="admin_main_filtered_router")
|
||||
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
||||
|
||||
@@ -3,13 +3,202 @@ import asyncio
|
||||
from aiogram import Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Enhanced notification service for sending messages to admins and log channels"""
|
||||
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
"""Send message to configured log channel/group"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
# Use thread_id if provided, otherwise use from settings
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
kwargs = {
|
||||
"chat_id": self.settings.LOG_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}
|
||||
|
||||
# Add thread ID for supergroups if specified
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
|
||||
await self.bot.send_message(**kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
"""Send message to all admin users"""
|
||||
if not self.settings.ADMIN_IDS:
|
||||
return
|
||||
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
|
||||
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
referred_by_id: Optional[int] = None):
|
||||
"""Send notification about new user registration"""
|
||||
if not self.settings.LOG_NEW_USERS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = first_name or f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
||||
|
||||
message = _(
|
||||
"log_new_user_registration",
|
||||
default="👤 <b>Новый пользователь</b>\n\n"
|
||||
"🆔 ID: <code>{user_id}</code>\n"
|
||||
"👤 Имя: {user_display}{referral_text}\n"
|
||||
"📅 Время: {timestamp}",
|
||||
user_id=user_id,
|
||||
user_display=user_display,
|
||||
referral_text=referral_text,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||
months: int, payment_provider: str,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about successful payment"""
|
||||
if not self.settings.LOG_PAYMENTS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
|
||||
provider_emoji = {
|
||||
"yookassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
}.get(payment_provider.lower(), "💰")
|
||||
|
||||
message = _(
|
||||
"log_payment_received",
|
||||
default="{provider_emoji} <b>Получен платеж</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"💰 Сумма: <b>{amount} {currency}</b>\n"
|
||||
"📅 Период: <b>{months} мес.</b>\n"
|
||||
"🏦 Провайдер: {payment_provider}\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
provider_emoji=provider_emoji,
|
||||
user_display=user_display,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
months=months,
|
||||
payment_provider=payment_provider,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about promo code activation"""
|
||||
if not self.settings.LOG_PROMO_ACTIVATIONS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
default="🎁 <b>Активирован промокод</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"🏷 Код: <code>{promo_code}</code>\n"
|
||||
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
promo_code=promo_code,
|
||||
bonus_days=bonus_days,
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about trial activation"""
|
||||
if not self.settings.LOG_TRIAL_ACTIVATIONS:
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
default="🆓 <b>Активирован триал</b>\n\n"
|
||||
"👤 Пользователь: {user_display}\n"
|
||||
"⏰ Действует до: <b>{end_date}</b>\n"
|
||||
"🕐 Время: {timestamp}",
|
||||
user_display=user_display,
|
||||
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
|
||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||
"""Send custom notification message"""
|
||||
if to_log_channel:
|
||||
await self._send_to_log_channel(message, thread_id)
|
||||
if to_admins:
|
||||
await self._send_to_admins(message)
|
||||
|
||||
|
||||
# Legacy functions for backward compatibility
|
||||
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
message_key: str, parse_mode: str | None = None,
|
||||
**kwargs) -> None:
|
||||
@@ -26,15 +215,9 @@ async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
|
||||
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
user_id: int, end_date: datetime) -> None:
|
||||
end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date)
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_new_trial_notification",
|
||||
user_id=user_id,
|
||||
end_date=end_date_str,
|
||||
)
|
||||
"""Send notification to admins about new trial activation (legacy)"""
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_trial_activation(user_id, end_date)
|
||||
|
||||
|
||||
async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
@@ -65,4 +248,4 @@ async def notify_admin_promo_activation(bot: Bot, settings: Settings,
|
||||
user_id=user_id,
|
||||
code=code,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from db.models import PromoCode, User
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import notify_admin_promo_activation
|
||||
from .notification_service import notify_admin_promo_activation, NotificationService
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
@@ -62,6 +62,20 @@ class PromoCodeService:
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
if activation_recorded and promo_incremented:
|
||||
# Send notification about promo activation
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send promo activation notification: {e}")
|
||||
|
||||
# Legacy notification for backwards compatibility
|
||||
await notify_admin_promo_activation(
|
||||
self.bot,
|
||||
self.settings,
|
||||
|
||||
@@ -7,7 +7,13 @@ class AdminStates(StatesGroup):
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
waiting_for_bulk_promo_details = State()
|
||||
waiting_for_user_id_to_ban = State()
|
||||
waiting_for_user_id_to_unban = State()
|
||||
|
||||
waiting_for_user_id_for_logs = State()
|
||||
|
||||
# User management states
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
|
||||
Reference in New Issue
Block a user