Compare commits

...
18 Commits
Author SHA1 Message Date
Machka PaslaandGitHub dbb27ee9ca Merge pull request #70 from machka-pasla/dev
Broadcast fix
2025-08-09 10:00:30 +03:00
machka-pasla 4e58fda4a5 Update broadcast message handler to enforce HTML parsing and disable web page previews
- Changed the parse_mode to "HTML" for broadcast messages to ensure proper formatting.
- Added disable_web_page_preview option to enhance message presentation and control over content display.
2025-08-09 09:51:21 +03:00
machka-pasla 8eb5daada5 Add HTML validation for broadcast messages in admin handler
- Implemented a preliminary check for HTML validity in broadcast messages by attempting to send a test message before processing.
- Added error handling for invalid HTML, providing user feedback in both English and Russian.
- Updated localization files to include new error messages for invalid HTML input.
2025-08-09 09:46:33 +03:00
machka-pasla 4c28d3868c Implement validation for broadcast message input in admin handler
- Updated the broadcast message handler to trim whitespace from the input text and added a check for empty messages.
- If the message is empty, an error response is sent to prompt the user for valid input, enhancing user experience and preventing empty broadcasts.
2025-08-09 09:43:02 +03:00
Machka PaslaandGitHub 08810eb2e0 Merge pull request #69 from machka-pasla/dev
Update promo_edit_field_handler to clarify callback data structure
2025-08-07 23:57:59 +03:00
machka-pasla b5d3b7b9c7 Update promo_edit_field_handler to clarify callback data structure
- Changed the variable name from 'i18n' to 'action' for better readability and understanding of the callback data being processed.
- Ensured that the promo ID and field to edit are still correctly extracted and updated in the state management.
2025-08-07 23:55:43 +03:00
Machka PaslaandGitHub 6330d57c60 Merge pull request #68 from machka-pasla/dev
Tribute hotfix, payments logs
2025-08-07 23:51:55 +03:00
machka-pasla 149ff057a9 Refactor subscription synchronization logic for improved handling
- Enhanced the subscription syncing process to prioritize concrete subscription UUIDs for updates and creations, ensuring idempotency.
- Implemented atomic updates for existing subscriptions and streamlined the creation of new subscriptions when a UUID is available.
- Improved logging for subscription updates and creations to provide clearer feedback on sync actions.
- Added handling for cases where no subscription UUID is present, avoiding unnecessary record creation.
2025-08-07 23:47:19 +03:00
machka-pasla bb7641bb74 Improve amount conversion in TributeService to handle minor units
- Updated the amount handling logic to convert minor currency units (kopecks/cents) to major units before persisting.
- Added error handling for invalid amount inputs to ensure robustness in processing payment data.
- Ensured that the amount is rounded to two decimal places for accurate representation in the system.
2025-08-07 23:39:48 +03:00
machka-pasla 18f65ea493 Refactor notification handling and streamline router registration
- Replaced legacy notification functions with a unified NotificationService for better maintainability and clarity.
- Updated the main bot router registration to utilize a root router, simplifying the inclusion of user and admin routes.
- Removed unused middleware and helper functions to enhance code cleanliness and focus on essential components.
- Improved localization by adding new error messages for user interactions.
2025-08-07 23:30:32 +03:00
machka-pasla 5853b9da63 Enhance payment identifier normalization in TributeService
- Updated the TributeService to normalize provider payment identifiers, prioritizing true payment identifiers over subscription IDs for better uniqueness.
- Implemented fallback logic to append timestamps to subscription IDs, preventing deduplication of renewals.
- Expanded the list of successful charge events to include various payment-related events, improving event handling consistency.
2025-08-07 23:02:23 +03:00
machka-pasla 91cfe0baf3 Add payments feature to admin panel
- Integrated payments functionality into the admin panel by adding a new payments router and corresponding handlers.
- Updated the admin panel actions to include a view payments option, enhancing admin capabilities.
- Implemented new database functions to retrieve successful payment counts and details for export.
- Enhanced localization with new strings for payments management in both English and Russian.
2025-08-07 22:48:09 +03:00
machka-pasla 194f1b9e49 Enhance recent payment log retrieval to filter by succeeded status
- Updated the `get_recent_payment_logs_with_user` function to include a filter for payments with a 'succeeded' status, improving the relevance of retrieved payment logs.
- Adjusted the query structure for better readability and maintainability.
2025-08-07 19:00:27 +03:00
machka-pasla df15cfd25e Refactor promo handler functions to include session management
- Updated the promo_delete_handler, promo_edit_select_handler, and promo_edit_field_handler functions to accept an AsyncSession parameter, improving database interaction consistency.
- Enhanced the handling of expired subscriptions in the PanelWebhookService by modifying notification logic to only send messages if enabled, ensuring better control over user notifications.
- Added an import for the 'and_' function in payment_dal.py to support more complex query conditions.
2025-08-07 18:46:19 +03:00
machka-pasla 57e693fa37 Refactor synchronization messaging for clarity and simplicity
- Updated synchronization messages to provide simpler, more concise feedback to admins during the sync process.
- Replaced detailed sync status messages with straightforward notifications for success, failure, and errors.
- Enhanced localization for new message formats to improve user experience across languages.
2025-08-06 22:26:07 +03:00
Machka PaslaandGitHub 60ea6fff0d Merge pull request #67 from machka-pasla/dev
fix bug
2025-08-06 20:22:20 +03:00
machka-pasla 3cee4b243a Refactor details string handling in statistics display
- Simplified the logic for displaying synchronization details by removing the character limit, ensuring full visibility of the details or defaulting to "N/A" when not available.
- Improved code readability by streamlining the assignment of the details string.
2025-08-06 20:19:45 +03:00
machka-pasla a42f80160b Refactor broadcast confirmation prompt and enhance sync notification handling
- Updated the broadcast confirmation prompt to display the full message instead of a truncated preview.
- Improved error handling in the sync process by removing character limits on error details and ensuring comprehensive logging.
- Added a notification feature to inform admins about the panel synchronization status, including success and failure details.
- Enhanced localization for the broadcast confirmation prompt and added new log messages for sync notifications.
2025-08-06 19:04:03 +03:00
21 changed files with 776 additions and 345 deletions
+2
View File
@@ -7,6 +7,7 @@ from . import user_management
from . import statistics from . import statistics
from . import sync_admin from . import sync_admin
from . import logs_admin from . import logs_admin
from . import payments
admin_router_aggregate = Router(name="admin_features_router") admin_router_aggregate = Router(name="admin_features_router")
@@ -17,5 +18,6 @@ admin_router_aggregate.include_router(user_management.router)
admin_router_aggregate.include_router(statistics.router) admin_router_aggregate.include_router(statistics.router)
admin_router_aggregate.include_router(sync_admin.router) admin_router_aggregate.include_router(sync_admin.router)
admin_router_aggregate.include_router(logs_admin.router) admin_router_aggregate.include_router(logs_admin.router)
admin_router_aggregate.include_router(payments.router)
__all__ = ("admin_router_aggregate", ) __all__ = ("admin_router_aggregate", )
+37 -6
View File
@@ -1,7 +1,7 @@
import logging import logging
import asyncio import asyncio
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramRetryAfter from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from typing import Optional from typing import Optional
@@ -58,13 +58,14 @@ async def broadcast_message_prompt_handler(
await state.set_state(AdminStates.waiting_for_broadcast_message) await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message, F.text) @router.message(AdminStates.waiting_for_broadcast_message)
async def process_broadcast_message_handler( async def process_broadcast_message_handler(
message: types.Message, message: types.Message,
state: FSMContext, state: FSMContext,
i18n_data: dict, i18n_data: dict,
settings: Settings, settings: Settings,
session: AsyncSession, session: AsyncSession,
bot: Bot,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -76,16 +77,45 @@ async def process_broadcast_message_handler(
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Сохраняем в state исходный текст и entities # Сохраняем в state исходный текст и entities
text = message.text or message.caption or "" text = (message.text or message.caption or "").strip()
entities = message.entities or message.caption_entities or [] entities = message.entities or message.caption_entities or []
# Если текст пустой (например, прислали стикер/фото без подписи) — просим ввести текст
if not text:
await message.answer(_("admin_broadcast_error_no_message"))
return
# Предварительная проверка HTML: попробуем отправить и сразу удалить
# Если HTML некорректный, Telegram вернёт ошибку парсинга
try:
test_msg = await bot.send_message(
chat_id=message.chat.id,
text=text,
parse_mode="HTML",
disable_web_page_preview=True,
disable_notification=True,
)
# Удалим тестовое сообщение
try:
await bot.delete_message(chat_id=message.chat.id, message_id=test_msg.message_id)
except Exception:
pass
except TelegramBadRequest as e:
await message.answer(
_(
"admin_broadcast_invalid_html",
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
error=str(e),
)
)
return
await state.update_data( await state.update_data(
broadcast_text=text, broadcast_text=text,
broadcast_entities=entities, broadcast_entities=entities,
) )
preview_snippet = (text[:200] + "...") if len(text) > 200 else text confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=text)
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
await message.answer( await message.answer(
confirmation_prompt, confirmation_prompt,
@@ -185,7 +215,8 @@ async def confirm_broadcast_callback_handler(
await queue_manager.send_message( await queue_manager.send_message(
chat_id=uid, chat_id=uid,
text=text, text=text,
entities=entities, parse_mode="HTML",
disable_web_page_preview=True,
) )
sent_count += 1 sent_count += 1
+4
View File
@@ -123,6 +123,10 @@ async def admin_panel_actions_callback_handler(
await callback.answer(_("admin_sync_initiated_from_panel")) await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status": elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data) 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 == "main": elif action == "main":
try: try:
await callback.message.edit_text( await callback.message.edit_text(
+245
View File
@@ -0,0 +1,245 @@
import logging
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
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) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in ['pending', 'pending_yookassa'] 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',
'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay'
}.get(payment.provider, payment.provider or 'Unknown')
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
f"💳 {provider_text}\n"
f"📅 {payment_date}\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 # Показываем по 5 платежей на странице
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", default="Платежи не найдены."),
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", default="💰 <b>Все платежи</b>")]
text_parts.append(f"📊 Показано {len(payments)} из {total_count} платежей (стр. {page + 1}/{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)}")
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", default="📊 Экспорт CSV"),
callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments", default="🔄 Обновить"),
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", default="Нет платежей для экспорта."),
show_alert=True
)
return
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
_("admin_csv_payment_id", default="ID"),
_("admin_csv_user_id", default="User ID"),
_("admin_csv_username", default="Username"),
_("admin_csv_first_name", default="First Name"),
_("admin_csv_amount", default="Amount"),
_("admin_csv_currency", default="Currency"),
_("admin_csv_provider", default="Provider"),
_("admin_csv_status", default="Status"),
_("admin_csv_description", default="Description"),
_("admin_csv_months", default="Months"),
_("admin_csv_created_at", default="Created At"),
_("admin_csv_provider_payment_id", default="Provider Payment ID")
])
# Write payment data
for payment in all_payments:
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 "",
payment.subscription_duration_months 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",
default="📊 Экспорт платежей завершен!\nВсего записей: {count}",
count=len(all_payments))
)
await callback.answer(
_("admin_export_sent", default="Файл отправлен!"),
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()
+5 -5
View File
@@ -332,7 +332,7 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
@router.callback_query(F.data.startswith("promo_delete:")) @router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession): async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language") current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang: if not i18n or not callback.message or not current_lang:
@@ -345,7 +345,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, s
if promo: if promo:
await session.commit() await session.commit()
await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True) await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
await promo_management_handler(callback, i18n_data, get_settings(), session, 0) await promo_management_handler(callback, i18n_data, settings, session, 0)
else: else:
await callback.answer(_("admin_promo_not_found"), show_alert=True) await callback.answer(_("admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError): except (ValueError, IndexError):
@@ -354,7 +354,7 @@ async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, s
# --- Promo Edit Handlers --- # --- Promo Edit Handlers ---
@router.callback_query(F.data.startswith("promo_edit_select:")) @router.callback_query(F.data.startswith("promo_edit_select:"))
async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict): async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language") current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang: if not i18n or not callback.message or not current_lang:
@@ -373,13 +373,13 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
@router.callback_query(F.data.startswith("promo_edit_field:")) @router.callback_query(F.data.startswith("promo_edit_field:"))
async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict): async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language") current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang: return if not i18n or not callback.message or not current_lang: return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
_, field, promo_id_str = callback.data.split(":") action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field) await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = { prompts = {
+1 -3
View File
@@ -197,9 +197,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A" '%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
details_val = sync_status_model.details details_val = sync_status_model.details
details_str = (details_val[:100] + details_str = details_val or "N/A"
"...") if details_val and len(details_val) > 100 else (
details_val or "N/A")
stats_text_parts.append( stats_text_parts.append(
f" {_('admin_stats_sync_time')}: {sync_time_str}") f" {_('admin_stats_sync_time')}: {sync_time_str}")
+91 -48
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from config.settings import Settings from config.settings import Settings
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from db.dal import user_dal, subscription_dal, panel_sync_dal from db.dal import user_dal, subscription_dal, panel_sync_dal
@@ -121,48 +122,88 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
panel_expire_at_iso.replace("Z", "+00:00") panel_expire_at_iso.replace("Z", "+00:00")
) )
# Update or create subscription # Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
active_sub = await subscription_dal.get_active_subscription_by_user_id( subscription_uuid_from_panel = (
session, actual_user_id, panel_uuid panel_user_dict.get("subscriptionUuid")
or panel_user_dict.get("shortUuid")
) )
if active_sub: if subscription_uuid_from_panel:
# Check if subscription needs update # Try to find subscription by its panel_subscription_uuid first (idempotent)
if (active_sub.end_date != panel_expire_at or existing_sub_by_uuid = (
active_sub.status_from_panel != panel_status or await subscription_dal.get_subscription_by_panel_subscription_uuid(
active_sub.is_active != (panel_status == "ACTIVE")): session, subscription_uuid_from_panel
)
)
await subscription_dal.update_subscription_end_date( if existing_sub_by_uuid:
session, active_sub.subscription_id, panel_expire_at # 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,
},
) )
# Update status fields
active_sub.status_from_panel = panel_status
active_sub.is_active = (panel_status == "ACTIVE")
subscriptions_synced_count += 1 subscriptions_synced_count += 1
subscriptions_updated += 1 subscriptions_updated += 1
user_was_updated = True user_was_updated = True
logging.info(f"Updated subscription for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}") logging.info(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else: else:
# Create new subscription record # Create a new subscription only when we have a concrete subscription UUID
subscription_uuid_to_use = panel_subscription_uuid or panel_uuid
logging.info(f"Creating new subscription for user {actual_user_id} with UUID {subscription_uuid_to_use}")
sub_payload = { sub_payload = {
"user_id": actual_user_id, "user_id": actual_user_id,
"panel_user_uuid": panel_uuid, "panel_user_uuid": panel_uuid,
"panel_subscription_uuid": subscription_uuid_to_use, "panel_subscription_uuid": subscription_uuid_from_panel,
"start_date": datetime.now(timezone.utc), # Do not guess precise start_date from panel; keep nullable
"start_date": None,
"end_date": panel_expire_at, "end_date": panel_expire_at,
"duration_months": 1, # Default "duration_months": None,
"is_active": panel_status == "ACTIVE", "is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status, "status_from_panel": panel_status,
"traffic_limit_bytes": settings.user_traffic_limit_bytes, "traffic_limit_bytes": settings.user_traffic_limit_bytes,
} }
await subscription_dal.upsert_subscription(session, sub_payload) created_sub = await subscription_dal.upsert_subscription(
session, sub_payload
)
subscriptions_synced_count += 1 subscriptions_synced_count += 1
subscriptions_created += 1 subscriptions_created += 1
user_was_updated = True user_was_updated = True
logging.info(
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
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.info(
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else:
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
logging.debug(
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
)
except Exception as e: except Exception as e:
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}") sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
@@ -222,7 +263,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
except Exception as e_sync_global: except Exception as e_sync_global:
await session.rollback() await session.rollback()
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True) logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
error_detail = f"Unexpected error during sync: {str(e_sync_global)[:200]}" error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
await panel_sync_dal.update_panel_sync_status( await panel_sync_dal.update_panel_sync_status(
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
@@ -264,7 +305,7 @@ async def sync_command_handler(
return return
if isinstance(message_event, types.Message): if isinstance(message_event, types.Message):
await message_event.answer(_("sync_started")) await message_event.answer(_("sync_started_simple"))
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.") logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
@@ -276,31 +317,37 @@ async def sync_command_handler(
details = sync_result.get("details", "No details available") details = sync_result.get("details", "No details available")
errors = sync_result.get("errors", []) errors = sync_result.get("errors", [])
# Simple confirmation message to admin
if status == "failed": if status == "failed":
await bot.send_message(target_chat_id, _("sync_failed", details=details)) await bot.send_message(target_chat_id, _("sync_failed_simple"))
elif status == "completed_with_errors": elif status == "completed_with_errors":
error_preview = "; ".join(errors[:3]) # Show first 3 errors await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
final_message = _(
"sync_completed_with_errors_details",
total_checked=sync_result.get("users_processed", 0),
users_synced=sync_result.get("users_synced", 0),
subs_synced=sync_result.get("subs_synced", 0),
errors_count=len(errors),
error_details_preview=error_preview[:200] + "..." if len(error_preview) > 200 else error_preview
)
await bot.send_message(target_chat_id, final_message)
else: else:
final_message = _( await bot.send_message(target_chat_id, _("sync_success_simple"))
"sync_completed_details",
total_checked=sync_result.get("users_processed", 0), # Send notification to log channel with proper thread handling
users_synced=sync_result.get("users_synced", 0), try:
subs_synced=sync_result.get("subs_synced", 0) 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)
) )
await bot.send_message(target_chat_id, _("sync_completed", status="Success", details=final_message)) except Exception as e_notification:
logging.error(f"Failed to send sync notification: {e_notification}")
except Exception as e_sync_global: except Exception as e_sync_global:
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True) logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
await bot.send_message(target_chat_id, _("sync_failed", details=str(e_sync_global))) 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")) @router.message(Command("syncstatus"))
@@ -323,11 +370,7 @@ async def sync_status_command_handler(
) )
details_val = status_record_model.details details_val = status_record_model.details
details_str = ( details_str = details_val or "N/A"
(details_val[:200] + "...")
if details_val and len(details_val) > 200
else (details_val or "N/A")
)
response_text = ( response_text = (
f"<b>{_('admin_stats_last_sync_header')}</b>\n" f"<b>{_('admin_stats_last_sync_header')}</b>\n"
+5 -15
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from config.settings import Settings from config.settings import Settings
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import notify_admin_new_trial from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import ( from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard, get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
@@ -97,13 +97,8 @@ async def request_trial_confirmation_handler(
) )
# Send notification to admin about new trial # Send notification to admin about new trial
await notify_admin_new_trial( notification_service = NotificationService(callback.bot, settings, i18n)
callback.bot, await notification_service.notify_trial_activation(user_id, end_date_obj)
settings,
i18n,
user_id,
end_date_obj,
)
else: else:
message_key_from_service = ( message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed") activation_result.get("message_key", "trial_activation_failed")
@@ -264,13 +259,8 @@ async def confirm_activate_trial_handler(
) )
if activation_result and activation_result.get("activated") and end_date_obj: if activation_result and activation_result.get("activated") and end_date_obj:
await notify_admin_new_trial( notification_service = NotificationService(callback.bot, settings, i18n)
callback.bot, await notification_service.notify_trial_activation(user_id, end_date_obj)
settings,
i18n,
user_id,
end_date_obj,
)
@router.callback_query(F.data == "main_action:cancel_trial") @router.callback_query(F.data == "main_action:cancel_trial")
+3 -1
View File
@@ -39,12 +39,14 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
builder.button(text=_(key="admin_stats_button"), builder.button(text=_(key="admin_stats_button"),
callback_data="admin_action:stats") callback_data="admin_action:stats")
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
callback_data="admin_action:view_payments")
builder.button(text=_(key="admin_view_logs_menu_button"), builder.button(text=_(key="admin_view_logs_menu_button"),
callback_data="admin_action:view_logs_menu") callback_data="admin_action:view_logs_menu")
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
builder.adjust(2, 1) builder.adjust(2, 1, 1)
return builder.as_markup() return builder.as_markup()
+6 -61
View File
@@ -1,17 +1,10 @@
import logging import logging
import asyncio import asyncio
from typing import Callable, Dict, Any, Awaitable, Optional from typing import Dict, Any, Optional
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F from aiogram import Bot, Dispatcher
from aiogram.types import ( from aiogram.types import (MenuButtonDefault, MenuButtonWebApp, WebAppInfo, BotCommand)
Update,
MenuButtonDefault,
MenuButtonWebApp,
WebAppInfo,
BotCommand,
)
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
@@ -24,13 +17,11 @@ from config.settings import Settings
from db.database_setup import init_db_connection from db.database_setup import init_db_connection
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.ban_check_middleware import BanCheckMiddleware from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.handlers.user import user_router_aggregate from bot.routers import build_root_router
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 from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
@@ -46,54 +37,8 @@ from bot.handlers.admin.sync_admin import perform_sync
from bot.utils.message_queue import init_queue_manager from bot.utils.message_queue import init_queue_manager
class DBSessionMiddleware(BaseMiddleware):
def __init__(self, async_session_factory: sessionmaker):
super().__init__()
self.async_session_factory = async_session_factory
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if self.async_session_factory is None:
logging.critical("DBSessionMiddleware: async_session_factory is None!")
raise RuntimeError(
"async_session_factory not provided to DBSessionMiddleware"
)
async with self.async_session_factory() as session:
data["session"] = session
try:
result = await handler(event, data)
await session.commit()
return result
except Exception:
await session.rollback()
logging.error(
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
)
raise
async def register_all_routers(dp: Dispatcher, settings: Settings): async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(user_router_aggregate) dp.include_router(build_root_router(settings))
# 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)
admin_main_router.message.filter(admin_filter_instance)
admin_main_router.callback_query.filter(admin_filter_instance)
admin_main_router.include_router(admin_router_aggregate)
dp.include_router(admin_main_router)
logging.info("All application routers registered.") logging.info("All application routers registered.")
+40
View File
@@ -0,0 +1,40 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Update
from sqlalchemy.orm import sessionmaker
class DBSessionMiddleware(BaseMiddleware):
def __init__(self, async_session_factory: sessionmaker):
super().__init__()
self.async_session_factory = async_session_factory
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if self.async_session_factory is None:
logging.critical("DBSessionMiddleware: async_session_factory is None!")
raise RuntimeError(
"async_session_factory not provided to DBSessionMiddleware"
)
async with self.async_session_factory() as session:
data["session"] = session
try:
result = await handler(event, data)
await session.commit()
return result
except Exception:
await session.rollback()
logging.error(
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
)
raise
+26
View File
@@ -0,0 +1,26 @@
from aiogram import Router
from bot.handlers.user import user_router_aggregate
from bot.handlers import inline_mode
from bot.handlers.admin import admin_router_aggregate
from bot.filters.admin_filter import AdminFilter
from config.settings import Settings
def build_root_router(settings: Settings) -> Router:
root = Router(name="root")
# Public routers
root.include_router(user_router_aggregate)
root.include_router(inline_mode.router)
# Admin routers behind filter
admin_main_router = Router(name="admin_main_filtered_router")
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
admin_main_router.message.filter(admin_filter_instance)
admin_main_router.callback_query.filter(admin_filter_instance)
admin_main_router.include_router(admin_router_aggregate)
root.include_router(admin_main_router)
return root
+37 -39
View File
@@ -221,6 +221,42 @@ class NotificationService:
# Send to log channel # Send to log channel
await self._send_to_log_channel(message) await self._send_to_log_channel(message)
async def notify_panel_sync(self, status: str, details: str,
users_processed: int, subs_synced: int,
username: Optional[str] = None):
"""Send notification about panel synchronization"""
if not getattr(self.settings, 'LOG_PANEL_SYNC', True):
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
# Status emoji based on sync result
status_emoji = {
"completed": "",
"completed_with_errors": "⚠️",
"failed": ""
}.get(status, "🔄")
message = _(
"log_panel_sync",
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
"📊 Статус: <b>{status}</b>\n"
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
"🕐 Время: {timestamp}\n\n"
"📝 Детали:\n{details}",
status_emoji=status_emoji,
status=status,
users_processed=users_processed,
subs_synced=subs_synced,
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
details=details
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_suspicious_promo_attempt( async def notify_suspicious_promo_attempt(
self, user_id: int, suspicious_input: str, self, user_id: int, suspicious_input: str,
username: Optional[str] = None, first_name: Optional[str] = None): username: Optional[str] = None, first_name: Optional[str] = None):
@@ -259,42 +295,4 @@ class NotificationService:
if to_admins: if to_admins:
await self._send_to_admins(message) await self._send_to_admins(message)
# Removed legacy helper functions that duplicated NotificationService API
# 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:
if not settings.ADMIN_IDS:
return
admin_lang = settings.DEFAULT_LANGUAGE
msg = i18n.gettext(admin_lang, message_key, **kwargs)
for admin_id in settings.ADMIN_IDS:
try:
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
except Exception as e:
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
user_id: int, end_date: datetime) -> None:
"""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_promo_activation(bot: Bot, settings: Settings,
i18n: JsonI18n, user_id: int,
code: str,
bonus_days: int) -> None:
await notify_admins(
bot,
settings,
i18n,
"admin_promo_activation_notification",
user_id=user_id,
code=code,
bonus_days=bonus_days,
)
+4 -2
View File
@@ -133,10 +133,12 @@ class PanelWebhookService:
user_name=first_name, user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10], end_date=user_payload.get("expireAt", "")[:10],
) )
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: elif event_name == "user.expired":
# Check if this is a tribute user that should be auto-renewed # Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
# Send notification only if enabled
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message( await self._send_message(
user_id, user_id,
lang, lang,
+42 -39
View File
@@ -34,10 +34,7 @@ class SubscriptionService:
else self.settings.DEFAULT_LANGUAGE else self.settings.DEFAULT_LANGUAGE
) )
async def has_had_any_subscription( async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
self, session: AsyncSession, user_id: int
) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id) return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def _notify_admin_panel_user_creation_failed(self, user_id: int): async def _notify_admin_panel_user_creation_failed(self, user_id: int):
@@ -348,18 +345,11 @@ class SubscriptionService:
"message_key": "trial_activation_failed_db", "message_key": "trial_activation_failed_db",
} }
panel_update_payload: Dict[str, Any] = { panel_update_payload = self._build_panel_update_payload(
"uuid": panel_user_uuid, panel_user_uuid=panel_user_uuid,
"expireAt": end_date.isoformat(timespec="milliseconds").replace( expire_at=end_date,
"+00:00", "Z" status="ACTIVE",
), traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
"status": "ACTIVE",
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
}
if self.settings.parsed_user_squad_uuids:
panel_update_payload["activeInternalSquads"] = (
self.settings.parsed_user_squad_uuids
) )
updated_panel_user = await self.panel_service.update_user_details_on_panel( updated_panel_user = await self.panel_service.update_user_details_on_panel(
@@ -495,18 +485,11 @@ class SubscriptionService:
) )
return None return None
panel_update_payload = { panel_update_payload = self._build_panel_update_payload(
"uuid": panel_user_uuid, panel_user_uuid=panel_user_uuid,
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace( expire_at=final_end_date,
"+00:00", "Z" status="ACTIVE",
), traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
"status": "ACTIVE",
"trafficLimitBytes": self.settings.user_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
}
if self.settings.parsed_user_squad_uuids:
panel_update_payload["activeInternalSquads"] = (
self.settings.parsed_user_squad_uuids
) )
updated_panel_user = await self.panel_service.update_user_details_on_panel( updated_panel_user = await self.panel_service.update_user_details_on_panel(
@@ -598,17 +581,13 @@ class SubscriptionService:
if updated_sub_model: if updated_sub_model:
# Prepare panel update payload # Prepare panel update payload
panel_update_payload = { panel_update_payload = self._build_panel_update_payload(
"expireAt": new_end_date_obj.isoformat( expire_at=new_end_date_obj,
timespec="milliseconds" traffic_limit_bytes=(
).replace("+00:00", "Z") self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
} ),
include_uuid=False,
# For promo code activations, remove traffic limit )
if "promo code" in reason.lower():
panel_update_payload["trafficLimitBytes"] = self.settings.user_traffic_limit_bytes
panel_update_payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
logging.info(f"Updating traffic limit for user {user_id} to {self.settings.user_traffic_limit_bytes} bytes due to promo code activation")
panel_update_success = ( panel_update_success = (
await self.panel_service.update_user_details_on_panel( await self.panel_service.update_user_details_on_panel(
@@ -775,3 +754,27 @@ class SubscriptionService:
logging.warning( logging.warning(
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
) )
# Helpers
def _build_panel_update_payload(
self,
*,
panel_user_uuid: Optional[str] = None,
expire_at: Optional[datetime] = None,
status: Optional[str] = None,
traffic_limit_bytes: Optional[int] = None,
include_uuid: bool = True,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
if include_uuid and panel_user_uuid:
payload["uuid"] = panel_user_uuid
if expire_at is not None:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if status is not None:
payload["status"] = status
if traffic_limit_bytes is not None:
payload["trafficLimitBytes"] = traffic_limit_bytes
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
if self.settings.parsed_user_squad_uuids:
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
return payload
+56 -69
View File
@@ -39,11 +39,16 @@ def convert_period_to_months(period: Optional[str]) -> int:
class TributeService: class TributeService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker, async_session_factory: sessionmaker,
panel_service: PanelApiService, panel_service: PanelApiService,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService): referral_service: ReferralService,
):
self.bot = bot self.bot = bot
self.settings = settings self.settings = settings
self.i18n = i18n self.i18n = i18n
@@ -52,8 +57,7 @@ class TributeService:
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.referral_service = referral_service self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes, async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
signature_header: Optional[str]) -> web.Response:
settings = self.settings settings = self.settings
bot = self.bot bot = self.bot
i18n = self.i18n i18n = self.i18n
@@ -79,60 +83,60 @@ class TributeService:
json.dumps(payload, ensure_ascii=False), json.dumps(payload, ensure_ascii=False),
) )
event_name = payload.get('name') # Tribute webhook spec: only two events are sent
data = payload.get('payload', {}) # name: new_subscription | cancelled_subscription
user_id = data.get('telegram_user_id') event_name = payload.get("name")
price_val = ( data = payload.get("payload", {})
data.get('amount')
or data.get('amount_paid')
or data.get('price')
)
if not user_id or price_val is None: # Mandatory routing fields
return web.Response(status=200, text="ok_missing_fields") user_id = data.get("telegram_user_id")
if not user_id:
return web.Response(status=400, text="missing_telegram_user_id")
period_val = data.get('period') period_val = data.get("period")
months = convert_period_to_months(period_val) months = convert_period_to_months(period_val)
price_rub = price_val / 100
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
amount_value = data.get("amount") or data.get("price")
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
if amount_value is not None:
try:
amount_minor_units = float(amount_value)
except (TypeError, ValueError):
amount_minor_units = 0.0
amount_float = round(amount_minor_units / 100.0, 2)
else:
amount_float = 0.0
async with async_session_factory() as session: async with async_session_factory() as session:
if event_name == 'new_subscription': if event_name == "new_subscription":
provider_payment_id = str(data.get('subscription_id')) # Build a stable provider payment id from subscription and timestamps
existing_payment = await payment_dal.get_payment_by_provider_payment_id( provider_payment_id = str(data.get("subscription_id"))
session, provider_payment_id) # Idempotent ensure payment
if existing_payment: payment_record = await payment_dal.ensure_payment_with_provider_id(
logging.info(
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
provider_payment_id,
)
payment_record = existing_payment
else:
payment_record = await payment_dal.create_payment_record(
session, session,
{ user_id=int(user_id),
'user_id': user_id, amount=amount_float,
'amount': float(price_rub), currency=currency,
'currency': 'RUB', months=months,
'status': 'succeeded', description="Tribute subscription",
'description': 'Tribute subscription', provider="tribute",
'subscription_duration_months': months, provider_payment_id=provider_payment_id,
'provider_payment_id': provider_payment_id,
'provider': 'tribute',
},
) )
activation_details = await subscription_service.activate_subscription( activation_details = await subscription_service.activate_subscription(
session, session,
user_id, int(user_id),
months, months,
float(price_rub), float(amount_float),
payment_record.payment_id, payment_record.payment_id,
provider='tribute', provider="tribute",
) )
referral_bonus = await referral_service.apply_referral_bonuses_for_payment( referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, months) session, int(user_id), months)
await session.commit() await session.commit()
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, int(user_id))
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) _ = lambda k, **kw: i18n.gettext(lang, k, **kw)
@@ -177,7 +181,7 @@ class TributeService:
try: try:
await bot.send_message( await bot.send_message(
user_id, int(user_id),
success_msg, success_msg,
reply_markup=markup, reply_markup=markup,
parse_mode="HTML", parse_mode="HTML",
@@ -190,21 +194,19 @@ class TributeService:
# Send notification about payment # Send notification about payment
try: try:
notification_service = NotificationService(bot, settings, i18n) notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id) user = await user_dal.get_user_by_id(session, int(user_id))
await notification_service.notify_payment_received( await notification_service.notify_payment_received(
user_id=user_id, user_id=int(user_id),
amount=float(price_rub), amount=float(amount_float),
currency="RUB", currency=currency,
months=months, months=months,
payment_provider="tribute", payment_provider="tribute",
username=user.username if user else None username=user.username if user else None
) )
except Exception as e: except Exception as e:
logging.error(f"Failed to send tribute payment notification: {e}") logging.error(f"Failed to send tribute payment notification: {e}")
elif event_name == "cancelled_subscription":
elif event_name == 'subscription_cancelled': await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
# Handle tribute subscription cancellation
await self._handle_tribute_cancellation(session, user_id, bot, i18n)
else: else:
await session.commit() await session.commit()
@@ -218,22 +220,7 @@ class TributeService:
try: try:
# Set all user's subscriptions to expire in 1 day (grace period) # Set all user's subscriptions to expire in 1 day (grace period)
grace_end_date = datetime.now(timezone.utc) + timedelta(days=1) await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
# Get all active subscriptions for the user
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
for sub in user_subs:
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
'end_date': grace_end_date,
'status_from_panel': 'CANCELLED',
'skip_notifications': True # Skip future notifications for cancelled subs
}
)
await session.commit() await session.commit()
# Send notification about cancellation if enabled # Send notification about cancellation if enabled
@@ -256,7 +243,7 @@ class TributeService:
try: try:
await bot.send_message( await bot.send_message(
user_id, int(user_id),
cancellation_msg, cancellation_msg,
reply_markup=markup, reply_markup=markup,
parse_mode="HTML" parse_mode="HTML"
+53 -3
View File
@@ -2,7 +2,7 @@ import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy import update, func from sqlalchemy import update, func, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from db.models import Payment, User from db.models import Payment, User
@@ -47,6 +47,38 @@ async def get_payment_by_provider_payment_id(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def ensure_payment_with_provider_id(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
months: int,
description: str,
provider: str,
provider_payment_id: str) -> Payment:
"""Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new succeeded payment with provided data.
"""
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing:
return existing
payment_payload: Dict[str, Any] = {
"user_id": user_id,
"amount": float(amount),
"currency": currency,
"status": "succeeded",
"description": description,
"subscription_duration_months": months,
"provider_payment_id": provider_payment_id,
"provider": provider,
}
return await create_payment_record(session, payment_payload)
async def get_payment_by_db_id(session: AsyncSession, async def get_payment_by_db_id(session: AsyncSession,
payment_db_id: int) -> Optional[Payment]: payment_db_id: int) -> Optional[Payment]:
@@ -82,8 +114,26 @@ async def update_payment_status_by_db_id(
async def get_recent_payment_logs_with_user(session: AsyncSession, async def get_recent_payment_logs_with_user(session: AsyncSession,
limit: int = 20, limit: int = 20,
offset: int = 0) -> List[Payment]: offset: int = 0) -> List[Payment]:
stmt = (select(Payment).options(selectinload(Payment.user)).order_by( stmt = (select(Payment).options(selectinload(Payment.user))
Payment.created_at.desc()).limit(limit).offset(offset)) .where(Payment.status == 'succeeded')
.order_by(Payment.created_at.desc())
.limit(limit).offset(offset))
result = await session.execute(stmt)
return result.scalars().all()
async def get_payments_count(session: AsyncSession) -> int:
"""Get total count of successful payments."""
stmt = select(func.count(Payment.payment_id)).where(Payment.status == 'succeeded')
result = await session.execute(stmt)
return result.scalar() or 0
async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]:
"""Get all successful payments with user data for export."""
stmt = (select(Payment).options(selectinload(Payment.user))
.where(Payment.status == 'succeeded')
.order_by(Payment.created_at.desc()))
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().all() return result.scalars().all()
+23
View File
@@ -53,6 +53,29 @@ async def update_subscription(
return sub return sub
async def set_user_subscriptions_cancelled_with_grace(
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
"""Mark all active user subscriptions as cancelled with a short grace period.
Sets end_date to now + grace_days, status_from_panel to 'CANCELLED', and
skip future notifications to reduce noise after cancellation.
Returns number of updated rows.
"""
from datetime import datetime, timezone, timedelta
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.values(
end_date=grace_end,
status_from_panel="CANCELLED",
skip_notifications=True,
)
)
result = await session.execute(stmt)
return result.rowcount or 0
async def upsert_subscription(session: AsyncSession, async def upsert_subscription(session: AsyncSession,
sub_payload: Dict[str, Any]) -> Subscription: sub_payload: Dict[str, Any]) -> Subscription:
panel_sub_uuid = sub_payload.get("panel_subscription_uuid") panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
+1 -14
View File
@@ -30,20 +30,7 @@ async def get_user_by_panel_uuid(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user( ## Removed unused generic get_user helper to keep DAL explicit and simple
session: AsyncSession,
*,
user_id: Optional[int] = None,
username: Optional[str] = None,
panel_uuid: Optional[str] = None,
) -> Optional[User]:
if user_id is not None:
return await get_user_by_id(session, user_id)
if username is not None:
return await get_user_by_username(session, username)
if panel_uuid is not None:
return await get_user_by_panel_uuid(session, panel_uuid)
return None
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User: async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
+34 -6
View File
@@ -16,6 +16,7 @@
"choose_language": "Choose language / Выберите язык:", "choose_language": "Choose language / Выберите язык:",
"language_set_alert": "Language changed!", "language_set_alert": "Language changed!",
"error_occurred_try_again": "An error occurred, please try again.", "error_occurred_try_again": "An error occurred, please try again.",
"error_try_again": "Please try again.",
"error_displaying_menu": "Error displaying menu.", "error_displaying_menu": "Error displaying menu.",
"main_menu_unknown_action": "Unknown action.", "main_menu_unknown_action": "Unknown action.",
@@ -122,6 +123,28 @@
"admin_stats_recent_payments_header": "Recent Payments:", "admin_stats_recent_payments_header": "Recent Payments:",
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "No payments found yet.", "admin_stats_no_payments_found": "No payments found yet.",
"admin_view_payments_button": "💰 Payments",
"admin_payments_header": "💰 <b>All Payments</b>",
"admin_no_payments_found": "No payments found.",
"admin_export_payments_csv": "📊 Export CSV",
"admin_refresh_payments": "🔄 Refresh",
"admin_no_payments_to_export": "No payments to export.",
"admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}",
"admin_export_sent": "File sent!",
"admin_csv_payment_id": "ID",
"admin_csv_user_id": "User ID",
"admin_csv_username": "Username",
"admin_csv_first_name": "First Name",
"admin_csv_amount": "Amount",
"admin_csv_currency": "Currency",
"admin_csv_provider": "Provider",
"admin_csv_status": "Status",
"admin_csv_description": "Description",
"admin_csv_months": "Months",
"admin_csv_created_at": "Created At",
"admin_csv_provider_payment_id": "Provider Payment ID",
"admin_stats_last_sync_header": "Last Panel Sync:", "admin_stats_last_sync_header": "Last Panel Sync:",
"admin_stats_sync_time": "Time", "admin_stats_sync_time": "Time",
"admin_stats_sync_status": "Status", "admin_stats_sync_status": "Status",
@@ -131,7 +154,7 @@
"admin_sync_status_never_run": "Panel sync never run.", "admin_sync_status_never_run": "Panel sync never run.",
"admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):", "admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):",
"admin_broadcast_confirm_prompt": "You are about to send the following message (first 200 characters):\n\n{message_preview}\n\nConfirm sending?", "admin_broadcast_confirm_prompt": "You are about to send the following message:\n\n{message_preview}\n\nConfirm sending?",
"confirm_broadcast_send_button": "✅ Send", "confirm_broadcast_send_button": "✅ Send",
"cancel_broadcast_button": "❌ Cancel", "cancel_broadcast_button": "❌ Cancel",
"admin_broadcast_sending_started": "Starting broadcast...", "admin_broadcast_sending_started": "Starting broadcast...",
@@ -142,9 +165,13 @@
"admin_broadcast_cancelled_alert": "Broadcast cancelled!", "admin_broadcast_cancelled_alert": "Broadcast cancelled!",
"admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.", "admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.",
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)", "admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]", "admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
"admin_promo_invalid_code_format": "Code must be 330 alphanumeric characters.", "admin_promo_invalid_code_format": "Code must be 330 alphanumeric characters.",
"admin_promo_invalid_bonus_days": "Bonus days must be a positive number.",
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.", "admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.", "admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_invalid_values": "Invalid values. {error}", "admin_promo_invalid_values": "Invalid values. {error}",
@@ -226,11 +253,11 @@
"admin_log_user_not_found": "User \"{input}\" not found in bot database.", "admin_log_user_not_found": "User \"{input}\" not found in bot database.",
"admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):", "admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):",
"sync_started": "🔄 Starting data sync with panel...", "sync_started_simple": "🔄 Starting synchronization...",
"sync_failed": " Sync with panel failed. Details: {details}", "sync_success_simple": " Synchronization completed successfully",
"sync_completed": " Sync with panel completed. Status: {status}. Details: {details}", "sync_failed_simple": " Synchronization failed",
"sync_completed_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.", "sync_errors_simple": "⚠️ Synchronization completed with errors ({errors_count} errors)",
"sync_completed_with_errors_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.\nErrors: {errors_count}.\n\nFirst errors:\n{error_details_preview}", "sync_critical_error": "❌ Critical synchronization error",
"no_errors_placeholder": "none", "no_errors_placeholder": "none",
"admin_sync_initiated_from_panel": "Sync initiated...", "admin_sync_initiated_from_panel": "Sync initiated...",
"admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?", "admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?",
@@ -314,6 +341,7 @@
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}", "log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
"admin_general_cancel_operation": "Operation cancelled ❌", "admin_general_cancel_operation": "Operation cancelled ❌",
+33 -6
View File
@@ -16,6 +16,7 @@
"choose_language": "Выберите язык / Select language:", "choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!", "language_set_alert": "Язык изменен!",
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.", "error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
"error_try_again": "Попробуйте еще раз.",
"error_displaying_menu": "Ошибка отображения меню.", "error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.", "main_menu_unknown_action": "Неизвестное действие.",
@@ -122,6 +123,28 @@
"admin_stats_recent_payments_header": "Последние платежи:", "admin_stats_recent_payments_header": "Последние платежи:",
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "Платежей пока нет.", "admin_stats_no_payments_found": "Платежей пока нет.",
"admin_view_payments_button": "💰 Платежи",
"admin_payments_header": "💰 <b>Все платежи</b>",
"admin_no_payments_found": "Платежи не найдены.",
"admin_export_payments_csv": "📊 Экспорт CSV",
"admin_refresh_payments": "🔄 Обновить",
"admin_no_payments_to_export": "Нет платежей для экспорта.",
"admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}",
"admin_export_sent": "Файл отправлен!",
"admin_csv_payment_id": "ID",
"admin_csv_user_id": "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_months": "Месяцев",
"admin_csv_created_at": "Дата создания",
"admin_csv_provider_payment_id": "ID платежа в системе",
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:", "admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
"admin_stats_sync_time": "Время", "admin_stats_sync_time": "Время",
"admin_stats_sync_status": "Статус", "admin_stats_sync_status": "Статус",
@@ -131,7 +154,7 @@
"admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.", "admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):", "admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?", "admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?",
"confirm_broadcast_send_button": "✅ Отправить", "confirm_broadcast_send_button": "✅ Отправить",
"cancel_broadcast_button": "❌ Отмена", "cancel_broadcast_button": "❌ Отмена",
"admin_broadcast_sending_started": "Начинаю рассылку...", "admin_broadcast_sending_started": "Начинаю рассылку...",
@@ -145,6 +168,8 @@
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)", "admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]", "admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.", "admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.", "admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.", "admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_invalid_values": "Неверные значения. {error}", "admin_promo_invalid_values": "Неверные значения. {error}",
@@ -236,13 +261,14 @@
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.", "admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):", "admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
"sync_started": "🔄 Начинаю синхронизацию данных с панелью...", "sync_started_simple": "🔄 Начинаю синхронизацию...",
"sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}", "sync_success_simple": "Синхронизация успешно завершена",
"sync_completed": " Синхронизация с панелью завершена. Статус: {status}. Детали: {details}", "sync_failed_simple": " Синхронизация завершилась с ошибкой",
"sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.", "sync_errors_simple": "⚠️ Синхронизация завершена с ошибками ({errors_count} ошибок)",
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}", "sync_critical_error": "❌ Критическая ошибка синхронизации",
"no_errors_placeholder": "нет", "no_errors_placeholder": "нет",
"admin_sync_initiated_from_panel": "Синхронизация запущена...", "admin_sync_initiated_from_panel": "Синхронизация запущена...",
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?", "admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.", "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.", "error_displaying_statistics": "Ошибка отображения статистики.",
@@ -323,6 +349,7 @@
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}", "log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
"admin_general_cancel_operation": "Операция отменена ❌", "admin_general_cancel_operation": "Операция отменена ❌",