Migrating to Postgres from sqlite3
This commit is contained in:
@@ -1,19 +1,21 @@
|
||||
from aiogram import Router
|
||||
|
||||
from .common import router as common_admin_router
|
||||
from .promo_codes import router as promo_codes_admin_router
|
||||
from .user_management import router as user_management_admin_router
|
||||
from .broadcast import router as broadcast_admin_router
|
||||
from .statistics import router as statistics_admin_router
|
||||
from .sync_admin import router as sync_admin_router
|
||||
from .logs_admin import router as logs_admin_router
|
||||
from . import common
|
||||
from . import broadcast
|
||||
from . import promo_codes
|
||||
from . import user_management
|
||||
from . import statistics
|
||||
from . import sync_admin
|
||||
from . import logs_admin
|
||||
|
||||
admin_router_aggregate = Router(name="admin_router_aggregate")
|
||||
admin_router_aggregate = Router(name="admin_features_router")
|
||||
|
||||
admin_router_aggregate.include_router(common_admin_router)
|
||||
admin_router_aggregate.include_router(promo_codes_admin_router)
|
||||
admin_router_aggregate.include_router(user_management_admin_router)
|
||||
admin_router_aggregate.include_router(broadcast_admin_router)
|
||||
admin_router_aggregate.include_router(statistics_admin_router)
|
||||
admin_router_aggregate.include_router(sync_admin_router)
|
||||
admin_router_aggregate.include_router(logs_admin_router)
|
||||
admin_router_aggregate.include_router(common.router)
|
||||
admin_router_aggregate.include_router(broadcast.router)
|
||||
admin_router_aggregate.include_router(promo_codes.router)
|
||||
admin_router_aggregate.include_router(user_management.router)
|
||||
admin_router_aggregate.include_router(statistics.router)
|
||||
admin_router_aggregate.include_router(sync_admin.router)
|
||||
admin_router_aggregate.include_router(logs_admin.router)
|
||||
|
||||
__all__ = ("admin_router_aggregate", )
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.database import get_all_users_for_broadcast, log_user_action
|
||||
from db.dal import user_dal, message_log_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
@@ -17,9 +19,9 @@ router = Router(name="admin_broadcast_router")
|
||||
|
||||
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in broadcast_message_prompt_handler")
|
||||
@@ -37,7 +39,8 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
current_lang, i18n))
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for broadcast prompt: {e}")
|
||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||
)
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
@@ -49,9 +52,9 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||
async def process_broadcast_message_handler(message: types.Message,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in process_broadcast_message_handler")
|
||||
@@ -63,8 +66,9 @@ async def process_broadcast_message_handler(message: types.Message,
|
||||
broadcast_message_text = message.html_text
|
||||
await state.update_data(broadcast_message=broadcast_message_text)
|
||||
|
||||
preview_snippet = broadcast_message_text[:200] + "..." if len(
|
||||
broadcast_message_text) > 200 else broadcast_message_text
|
||||
preview_snippet = (
|
||||
broadcast_message_text[:200] +
|
||||
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt",
|
||||
message_preview=preview_snippet)
|
||||
|
||||
@@ -79,34 +83,27 @@ async def process_broadcast_message_handler(message: types.Message,
|
||||
AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict):
|
||||
from .common import admin_panel_actions_callback_handler
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(_("admin_broadcast_cancelled"),
|
||||
reply_markup=None)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled"))
|
||||
await callback.answer(_("admin_broadcast_cancelled"))
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||
await state.clear()
|
||||
|
||||
callback.data = "admin_action:main"
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
|
||||
if callback.message:
|
||||
await callback.message.answer(_("admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang))
|
||||
await callback.message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("broadcast_final_action:"),
|
||||
@@ -114,80 +111,87 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict, bot: Bot,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing broadcast confirmation.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
action = callback.data.split(":")[1]
|
||||
user_fsm_data = await state.get_data()
|
||||
broadcast_message = user_fsm_data.get("broadcast_message")
|
||||
|
||||
if not callback.message:
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
await state.clear()
|
||||
return
|
||||
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
|
||||
|
||||
if action == "send":
|
||||
if not broadcast_message:
|
||||
if not broadcast_message_to_send:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(show_alert=True)
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"),
|
||||
reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
users_to_broadcast = await get_all_users_for_broadcast()
|
||||
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
|
||||
session)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
logging.info(
|
||||
f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users."
|
||||
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
|
||||
)
|
||||
|
||||
admin_user = callback.from_user
|
||||
|
||||
for user_row in users_to_broadcast:
|
||||
user_id = user_row['user_id']
|
||||
for user_id_to_send in user_ids_for_broadcast:
|
||||
try:
|
||||
await bot.send_message(user_id,
|
||||
broadcast_message,
|
||||
await bot.send_message(user_id_to_send,
|
||||
broadcast_message_to_send,
|
||||
parse_mode="HTML")
|
||||
sent_count += 1
|
||||
|
||||
await log_user_action(
|
||||
user_id=admin_user.id,
|
||||
telegram_username=admin_user.username,
|
||||
telegram_first_name=admin_user.first_name,
|
||||
event_type="admin_broadcast_sent",
|
||||
content=f"To user {user_id}: {broadcast_message[:70]}...",
|
||||
is_admin_event=True,
|
||||
target_user_id=user_id)
|
||||
log_payload_sent = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content":
|
||||
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_sent)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}"
|
||||
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
|
||||
)
|
||||
|
||||
await log_user_action(
|
||||
user_id=admin_user.id,
|
||||
telegram_username=admin_user.username,
|
||||
telegram_first_name=admin_user.first_name,
|
||||
event_type="admin_broadcast_failed",
|
||||
content=
|
||||
f"For user {user_id}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
is_admin_event=True,
|
||||
target_user_id=user_id)
|
||||
log_payload_failed = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content":
|
||||
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_failed)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e_commit:
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats",
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count)
|
||||
|
||||
@@ -3,11 +3,13 @@ from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
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.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
|
||||
from . import broadcast as admin_broadcast_handlers
|
||||
from . import promo_codes as admin_promo_handlers
|
||||
@@ -20,10 +22,12 @@ router = Router(name="admin_common_router")
|
||||
|
||||
|
||||
@router.message(Command("admin"))
|
||||
async def admin_panel_command_handler(message: types.Message,
|
||||
settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
async def admin_panel_command_handler(
|
||||
message: types.Message,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in admin_panel_command_handler")
|
||||
@@ -37,16 +41,14 @@ async def admin_panel_command_handler(message: types.Message,
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_action:"))
|
||||
async def admin_panel_actions_callback_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict, bot: Bot,
|
||||
panel_service: PanelApiService):
|
||||
async def admin_panel_actions_callback_handler(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
|
||||
i18n_data: dict, bot: Bot, panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
action_parts = callback.data.split(":")
|
||||
action = action_parts[1]
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in admin_panel_actions_callback_handler")
|
||||
@@ -56,7 +58,7 @@ async def admin_panel_actions_callback_handler(callback: types.CallbackQuery,
|
||||
|
||||
if not callback.message:
|
||||
logging.error(
|
||||
f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for action {action}"
|
||||
f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}"
|
||||
)
|
||||
await callback.answer("Error processing action: message context lost.",
|
||||
show_alert=True)
|
||||
@@ -64,36 +66,38 @@ async def admin_panel_actions_callback_handler(callback: types.CallbackQuery,
|
||||
|
||||
if action == "stats":
|
||||
await admin_stats_handlers.show_statistics_handler(
|
||||
callback, i18n_data, settings)
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "broadcast":
|
||||
await admin_broadcast_handlers.broadcast_message_prompt_handler(
|
||||
callback, state, i18n_data, settings)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "create_promo":
|
||||
await admin_promo_handlers.create_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_promos":
|
||||
await admin_promo_handlers.view_promo_codes_handler(
|
||||
callback, i18n_data, settings)
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "ban_user_prompt":
|
||||
await admin_user_mgmnt_handlers.ban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "unban_user_prompt":
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
await admin_user_mgmnt_handlers.view_banned_users_handler(
|
||||
callback, i18n_data, settings, state)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_logs_menu":
|
||||
await admin_logs_handlers.display_logs_menu(callback, i18n_data,
|
||||
settings)
|
||||
settings, session)
|
||||
elif action == "sync_panel":
|
||||
|
||||
await admin_sync_handlers.sync_command_handler(
|
||||
callback.message,
|
||||
message_event=callback,
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n_data=i18n_data,
|
||||
panel_service=panel_service)
|
||||
panel_service=panel_service,
|
||||
session=session)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "main":
|
||||
try:
|
||||
|
||||
+116
-114
@@ -4,18 +4,18 @@ import re
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, List, Dict, Any
|
||||
import aiosqlite
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import (get_all_message_logs_paginated,
|
||||
count_all_message_logs,
|
||||
get_user_message_logs_paginated,
|
||||
count_user_message_logs, get_user,
|
||||
get_user_by_telegram_username)
|
||||
|
||||
from db.dal import message_log_dal, user_dal
|
||||
from db.models import MessageLog, User
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_logs_menu_keyboard, get_logs_pagination_keyboard,
|
||||
get_back_to_admin_panel_keyboard, get_admin_panel_keyboard)
|
||||
get_back_to_admin_panel_keyboard)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_logs_router")
|
||||
@@ -23,19 +23,13 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
|
||||
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings):
|
||||
settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
if not i18n:
|
||||
logging.error("i18n_instance missing in display_logs_menu")
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying logs menu.", show_alert=True)
|
||||
return
|
||||
if not callback.message:
|
||||
logging.error("CallbackQuery has no message in display_logs_menu")
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
@@ -43,7 +37,8 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
||||
reply_markup=get_logs_menu_keyboard(
|
||||
i18n, current_lang))
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to edit message for logs menu: {e}")
|
||||
logging.warning(
|
||||
f"Failed to edit message for logs menu: {e}. Sending new.")
|
||||
await callback.message.answer(text=_(key="admin_logs_menu_title"),
|
||||
reply_markup=get_logs_menu_keyboard(
|
||||
i18n, current_lang))
|
||||
@@ -51,9 +46,9 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
|
||||
|
||||
|
||||
async def _display_formatted_logs(target_message: types.Message,
|
||||
logs: List[aiosqlite.Row],
|
||||
logs: List[MessageLog],
|
||||
total_logs: int,
|
||||
current_page: int,
|
||||
current_page_idx: int,
|
||||
settings: Settings,
|
||||
title_key: str,
|
||||
base_pagination_callback_data: str,
|
||||
@@ -63,7 +58,6 @@ async def _display_formatted_logs(target_message: types.Message,
|
||||
Any]] = None):
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
page_size = settings.LOGS_PAGE_SIZE
|
||||
|
||||
actual_title_kwargs = title_kwargs or {}
|
||||
|
||||
if not logs and total_logs == 0:
|
||||
@@ -71,7 +65,7 @@ async def _display_formatted_logs(target_message: types.Message,
|
||||
title_key, current_page=1, total_pages=1, **
|
||||
actual_title_kwargs) + "\n\n" + _("admin_no_logs_found")
|
||||
reply_markup = get_logs_pagination_keyboard(
|
||||
current_page,
|
||||
current_page_idx,
|
||||
1,
|
||||
base_pagination_callback_data,
|
||||
i18n,
|
||||
@@ -80,55 +74,46 @@ async def _display_formatted_logs(target_message: types.Message,
|
||||
else:
|
||||
total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1
|
||||
text = _(title_key,
|
||||
current_page=current_page + 1,
|
||||
current_page=current_page_idx + 1,
|
||||
total_pages=max(1, total_pages),
|
||||
**actual_title_kwargs) + "\n"
|
||||
|
||||
log_entries_text = []
|
||||
for log_entry in logs:
|
||||
for log_entry_model in logs:
|
||||
user_display_parts = []
|
||||
|
||||
telegram_first_name = log_entry[
|
||||
'telegram_first_name'] if 'telegram_first_name' in log_entry.keys(
|
||||
) and log_entry['telegram_first_name'] else None
|
||||
telegram_username = log_entry[
|
||||
'telegram_username'] if 'telegram_username' in log_entry.keys(
|
||||
) and log_entry['telegram_username'] else None
|
||||
user_id_from_log = log_entry[
|
||||
'user_id'] if 'user_id' in log_entry.keys(
|
||||
) and log_entry['user_id'] else None
|
||||
|
||||
if telegram_first_name:
|
||||
user_display_parts.append(telegram_first_name)
|
||||
if telegram_username:
|
||||
user_display_parts.append(f"(@{telegram_username})")
|
||||
if log_entry_model.telegram_first_name:
|
||||
user_display_parts.append(log_entry_model.telegram_first_name)
|
||||
if log_entry_model.telegram_username:
|
||||
user_display_parts.append(
|
||||
f"(@{log_entry_model.telegram_username})")
|
||||
|
||||
user_display = " ".join(user_display_parts).strip()
|
||||
if not user_display:
|
||||
user_display = _(
|
||||
"system_or_unknown_user"
|
||||
) if not user_id_from_log else f"ID: {user_id_from_log}"
|
||||
) if not log_entry_model.user_id else f"ID: {log_entry_model.user_id}"
|
||||
|
||||
user_id_display = str(
|
||||
user_id_from_log) if user_id_from_log is not None else "N/A"
|
||||
content_raw = log_entry['content'] if 'content' in log_entry.keys(
|
||||
) and log_entry['content'] else ""
|
||||
log_entry_model.user_id
|
||||
) if log_entry_model.user_id is not None else "N/A"
|
||||
content_raw = log_entry_model.content or ""
|
||||
content_preview = (content_raw[:100] +
|
||||
"...") if len(content_raw) > 100 else (
|
||||
content_raw or "N/A")
|
||||
|
||||
timestamp_str_display = log_entry_model.timestamp.strftime(
|
||||
'%Y-%m-%d %H:%M:%S') if log_entry_model.timestamp else 'N/A'
|
||||
|
||||
log_entries_text.append(
|
||||
_("admin_log_entry_format",
|
||||
timestamp_str=log_entry['timestamp_str']
|
||||
if 'timestamp_str' in log_entry.keys() else 'N/A',
|
||||
timestamp_str=timestamp_str_display,
|
||||
user_display=user_display,
|
||||
user_id=user_id_display,
|
||||
event_type=log_entry['event_type']
|
||||
if 'event_type' in log_entry.keys() else 'N/A',
|
||||
event_type=log_entry_model.event_type or 'N/A',
|
||||
content_preview=content_preview).replace("\n", "\n "))
|
||||
text += "\n\n".join(log_entries_text)
|
||||
reply_markup = get_logs_pagination_keyboard(
|
||||
current_page,
|
||||
current_page_idx,
|
||||
total_pages,
|
||||
base_pagination_callback_data,
|
||||
i18n,
|
||||
@@ -142,30 +127,40 @@ async def _display_formatted_logs(target_message: types.Message,
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to edit message for logs display: {e}. Content length: {len(text)}"
|
||||
f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)."
|
||||
)
|
||||
chunk_size = 4000
|
||||
for i in range(0, len(text), chunk_size):
|
||||
chunk = text[i:i + chunk_size]
|
||||
is_last_chunk = (i + chunk_size) >= len(text)
|
||||
await target_message.answer(
|
||||
chunk,
|
||||
reply_markup=reply_markup if is_last_chunk else None,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
|
||||
max_chunk_size = 4000
|
||||
for i in range(0, len(text), max_chunk_size):
|
||||
chunk = text[i:i + max_chunk_size]
|
||||
is_last_chunk = (i + max_chunk_size) >= len(text)
|
||||
try:
|
||||
await target_message.answer(
|
||||
chunk,
|
||||
reply_markup=reply_markup if is_last_chunk else None,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e_chunk:
|
||||
logging.error(f"Failed to send log chunk: {e_chunk}")
|
||||
|
||||
if i == 0:
|
||||
await target_message.answer(
|
||||
_("error_displaying_logs_too_long"),
|
||||
reply_markup=reply_markup if is_last_chunk else None)
|
||||
break
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_logs:view_all"))
|
||||
async def view_all_logs_handler(callback: types.CallbackQuery,
|
||||
settings: Settings, i18n_data: dict):
|
||||
page = 0
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
page_idx = 0
|
||||
parts = callback.data.split(":")
|
||||
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
page = int(parts[2])
|
||||
page_idx = int(parts[2])
|
||||
except ValueError:
|
||||
page = 0
|
||||
page_idx = 0
|
||||
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -173,15 +168,15 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
logs, total_logs = await get_all_message_logs_paginated(
|
||||
settings.LOGS_PAGE_SIZE,
|
||||
page * settings.LOGS_PAGE_SIZE), await count_all_message_logs()
|
||||
logs_models = await message_log_dal.get_all_message_logs(
|
||||
session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
|
||||
total_logs_count = await message_log_dal.count_all_message_logs(session)
|
||||
|
||||
await _display_formatted_logs(
|
||||
target_message=callback.message,
|
||||
logs=logs,
|
||||
total_logs=total_logs,
|
||||
current_page=page,
|
||||
logs=logs_models,
|
||||
total_logs=total_logs_count,
|
||||
current_page_idx=page_idx,
|
||||
settings=settings,
|
||||
title_key="admin_all_logs_title",
|
||||
base_pagination_callback_data="admin_logs:view_all",
|
||||
@@ -193,11 +188,13 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
|
||||
@router.callback_query(F.data == "admin_logs:prompt_user")
|
||||
async def prompt_user_for_logs_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error")
|
||||
await callback.answer("Error preparing user log prompt.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
@@ -211,103 +208,106 @@ async def prompt_user_for_logs_handler(callback: types.CallbackQuery,
|
||||
@router.message(AdminStates.waiting_for_user_id_for_logs, F.text)
|
||||
async def process_user_id_for_logs_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict):
|
||||
current_state_fsm = await state.get_state()
|
||||
logging.info(
|
||||
f"Processing user input for logs in state {current_state_fsm}: '{message.text}'"
|
||||
)
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
await state.clear()
|
||||
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
if not i18n:
|
||||
await message.reply("Language error.")
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
input_text = message.text.strip()
|
||||
user_data_for_logs: Optional[aiosqlite.Row] = None
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_model_for_logs: Optional[User] = None
|
||||
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_data_for_logs = await get_user(int(input_text))
|
||||
user_model_for_logs = await user_dal.get_user_by_id(
|
||||
session, int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_data_for_logs = await get_user_by_telegram_username(input_text[1:]
|
||||
)
|
||||
user_model_for_logs = await user_dal.get_user_by_username(
|
||||
session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_data_for_logs = await get_user_by_telegram_username(input_text)
|
||||
user_model_for_logs = await user_dal.get_user_by_username(
|
||||
session, input_text)
|
||||
|
||||
if not user_data_for_logs:
|
||||
if not user_model_for_logs:
|
||||
await message.answer(_("admin_log_user_not_found", input=input_text))
|
||||
return
|
||||
|
||||
target_user_id = user_data_for_logs['user_id']
|
||||
user_display = user_data_for_logs['first_name'] or (
|
||||
f"@{user_data_for_logs['username']}"
|
||||
if user_data_for_logs.get('username') else f"ID {target_user_id}")
|
||||
target_user_id = user_model_for_logs.user_id
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
|
||||
logs, total_logs = await get_user_message_logs_paginated(
|
||||
target_user_id, settings.LOGS_PAGE_SIZE,
|
||||
0), await count_user_message_logs(target_user_id)
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
|
||||
total_user_logs_count = await message_log_dal.count_user_message_logs(
|
||||
session, target_user_id)
|
||||
|
||||
await _display_formatted_logs(
|
||||
target_message=message,
|
||||
logs=logs,
|
||||
total_logs=total_logs,
|
||||
current_page=0,
|
||||
logs=logs_models,
|
||||
total_logs=total_user_logs_count,
|
||||
current_page_idx=0,
|
||||
settings=settings,
|
||||
title_key="admin_user_logs_title",
|
||||
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
title_kwargs={"user_display": user_display})
|
||||
title_kwargs={"user_display": user_display_name})
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_logs:view_user:"))
|
||||
async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict):
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
try:
|
||||
parts = callback.data.split(":")
|
||||
target_user_id = int(parts[2])
|
||||
page = int(parts[3])
|
||||
page_idx = int(parts[3])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid log request.", show_alert=True)
|
||||
await callback.answer("Invalid log request format.", show_alert=True)
|
||||
return
|
||||
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error")
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
|
||||
user_data_for_logs = await get_user(target_user_id)
|
||||
if not user_data_for_logs:
|
||||
user_model_for_logs = await user_dal.get_user_by_id(
|
||||
session, target_user_id)
|
||||
if not user_model_for_logs:
|
||||
await callback.message.edit_text("User not found for logs.")
|
||||
await callback.answer()
|
||||
return
|
||||
user_display = user_data_for_logs['first_name'] or (
|
||||
f"@{user_data_for_logs['username']}"
|
||||
if user_data_for_logs.get('username') else f"ID {target_user_id}")
|
||||
|
||||
logs, total_logs = await get_user_message_logs_paginated(
|
||||
target_user_id, settings.LOGS_PAGE_SIZE, page *
|
||||
settings.LOGS_PAGE_SIZE), await count_user_message_logs(target_user_id)
|
||||
user_display_name = user_model_for_logs.first_name or (
|
||||
f"@{user_model_for_logs.username}"
|
||||
if user_model_for_logs.username else f"ID {target_user_id}")
|
||||
|
||||
logs_models = await message_log_dal.get_user_message_logs(
|
||||
session, target_user_id, settings.LOGS_PAGE_SIZE,
|
||||
page_idx * settings.LOGS_PAGE_SIZE)
|
||||
total_user_logs_count = await message_log_dal.count_user_message_logs(
|
||||
session, target_user_id)
|
||||
|
||||
await _display_formatted_logs(
|
||||
target_message=callback.message,
|
||||
logs=logs,
|
||||
total_logs=total_logs,
|
||||
current_page=page,
|
||||
logs=logs_models,
|
||||
total_logs=total_user_logs_count,
|
||||
current_page_idx=page_idx,
|
||||
settings=settings,
|
||||
title_key="admin_user_logs_title",
|
||||
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
|
||||
i18n=i18n,
|
||||
current_lang=current_lang,
|
||||
title_kwargs={"user_display": user_display})
|
||||
title_kwargs={"user_display": user_display_name})
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -316,6 +316,8 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
|
||||
async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict):
|
||||
i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
await state.clear()
|
||||
await display_logs_menu(callback, i18n_data, settings)
|
||||
|
||||
await display_logs_menu(callback, i18n_data, settings, session)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import create_promo_code_db, get_promo_codes_db
|
||||
|
||||
from db.dal import promo_code_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_promo_codes_router")
|
||||
@@ -16,32 +18,29 @@ router = Router(name="admin_promo_codes_router")
|
||||
|
||||
async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in create_promo_prompt_handler")
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error preparing promo creation.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _("admin_promo_create_prompt",
|
||||
example_format="MYPROMO20 7 100 30")
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
except Exception as e:
|
||||
logging.warning(f"Could not edit message for promo prompt: {e}")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
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 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_promo_details)
|
||||
|
||||
@@ -50,87 +49,112 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
|
||||
async def process_promo_code_details_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in process_promo_code_details_handler")
|
||||
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_promo_invalid_format"))
|
||||
return
|
||||
|
||||
try:
|
||||
code = parts[0].upper()
|
||||
if not (3 <= len(code) <= 30 and code.isalnum()):
|
||||
raise ValueError(
|
||||
"Promo code must be 3-30 alphanumeric characters.")
|
||||
code_str = parts[0].upper()
|
||||
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
|
||||
raise ValueError(_("admin_promo_invalid_code_format"))
|
||||
|
||||
bonus_days = int(parts[1])
|
||||
max_activations = int(parts[2])
|
||||
|
||||
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(
|
||||
"Validity days (if provided) must be positive.")
|
||||
|
||||
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'))
|
||||
|
||||
if bonus_days <= 0 or max_activations <= 0:
|
||||
raise ValueError(
|
||||
"Bonus days and max activations must be positive.")
|
||||
raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(_("admin_promo_invalid_values", error=str(e)))
|
||||
return
|
||||
except Exception as e_parse:
|
||||
logging.error(
|
||||
f"Error parsing promo details '{message.text}': {e_parse}")
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
admin_id = message.from_user.id
|
||||
admin_id = message.from_user.id if message.from_user else 0
|
||||
|
||||
promo_id = await create_promo_code_db(code, bonus_days, max_activations,
|
||||
admin_id, valid_until_date)
|
||||
promo_data_to_create = {
|
||||
"code": code_str,
|
||||
"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
|
||||
}
|
||||
|
||||
if promo_id:
|
||||
success_text = _("admin_promo_created_success",
|
||||
code=code,
|
||||
bonus_days=bonus_days,
|
||||
max_activations=max_activations,
|
||||
valid_until_str=valid_until_str_display)
|
||||
await message.answer(success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
else:
|
||||
try:
|
||||
created_promo = await promo_code_dal.create_promo_code(
|
||||
session, promo_data_to_create)
|
||||
await session.commit()
|
||||
|
||||
if created_promo:
|
||||
success_text = _("admin_promo_created_success",
|
||||
code=created_promo.code,
|
||||
bonus_days=created_promo.bonus_days,
|
||||
max_activations=created_promo.max_activations,
|
||||
valid_until_str=valid_until_str_display)
|
||||
await message.answer(success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
else:
|
||||
await message.answer(
|
||||
_("admin_promo_creation_failed_duplicate", code=code_str))
|
||||
|
||||
except Exception as e_db_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create promo code '{code_str}' in DB: {e_db_create}",
|
||||
exc_info=True)
|
||||
await message.answer(_("admin_promo_creation_failed"))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in view_promo_codes_handler")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying promo codes.", show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promos = await get_promo_codes_db(is_active_only=True, limit=20)
|
||||
|
||||
if not callback.message:
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
return
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
|
||||
limit=20,
|
||||
offset=0)
|
||||
|
||||
if not promos:
|
||||
if not promo_models:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_active_promos"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
@@ -138,36 +162,18 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
return
|
||||
|
||||
response_text_parts = [f"<b>{_('admin_active_promos_list_header')}</b>\n"]
|
||||
for promo in promos:
|
||||
for promo in promo_models:
|
||||
valid_until_display_text = _("admin_promo_valid_indefinitely")
|
||||
if promo['valid_until']:
|
||||
try:
|
||||
if promo.valid_until:
|
||||
|
||||
valid_until_dt: Optional[datetime] = None
|
||||
if 'T' in promo['valid_until']:
|
||||
valid_until_dt = datetime.fromisoformat(
|
||||
promo['valid_until'].replace("Z", "+00:00"))
|
||||
else:
|
||||
valid_until_dt = datetime.strptime(promo['valid_until'],
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if valid_until_dt and valid_until_dt.tzinfo is None:
|
||||
valid_until_dt = valid_until_dt.replace(
|
||||
tzinfo=timezone.utc)
|
||||
|
||||
valid_until_display_text = valid_until_dt.strftime('%Y-%m-%d')
|
||||
except ValueError as e:
|
||||
logging.warning(
|
||||
f"Could not parse valid_until date string '{promo['valid_until']}' for promo code {promo['code']}: {e}"
|
||||
)
|
||||
valid_until_display_text = promo['valid_until']
|
||||
valid_until_display_text = promo.valid_until.strftime('%Y-%m-%d')
|
||||
|
||||
response_text_parts.append(
|
||||
_("admin_promo_list_item",
|
||||
code=promo['code'],
|
||||
bonus=promo['bonus_days'],
|
||||
current=promo['current_activations'],
|
||||
max=promo['max_activations'],
|
||||
code=promo.code,
|
||||
bonus=promo.bonus_days,
|
||||
current=promo.current_activations,
|
||||
max=promo.max_activations,
|
||||
valid_until=valid_until_display_text))
|
||||
|
||||
final_text = "\n".join(response_text_parts)
|
||||
@@ -177,7 +183,8 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to edit message for promo list: {e}")
|
||||
logging.warning(
|
||||
f"Failed to edit message for promo list: {e}. Sending new.")
|
||||
if callback.message:
|
||||
await callback.message.answer(
|
||||
final_text,
|
||||
@@ -185,3 +192,30 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_promo_details)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
|
||||
await callback.answer(_("admin_action_cancelled_default_alert"))
|
||||
await state.clear()
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import get_user_count_stats, get_payment_logs, get_message_logs_db, get_last_sync_status
|
||||
|
||||
from db.dal import user_dal, payment_dal, panel_sync_dal
|
||||
from db.models import Payment, PanelSyncStatus
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
@@ -12,67 +16,69 @@ router = Router(name="admin_statistics_router")
|
||||
|
||||
|
||||
async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in show_statistics_handler")
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying statistics.", show_alert=True)
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
|
||||
|
||||
user_stats = await get_user_count_stats()
|
||||
user_stats_dict = await user_dal.get_user_count_stats_dal(session)
|
||||
stats_text_parts.append(
|
||||
_("admin_stats_users",
|
||||
total_users=user_stats.get("total_users", 0),
|
||||
banned_users=user_stats.get("banned_users", 0),
|
||||
active_subs=user_stats.get("users_with_active_subscriptions", 0)))
|
||||
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)))
|
||||
|
||||
last_payments = await get_payment_logs(limit=5)
|
||||
if last_payments:
|
||||
last_payments_models: List[
|
||||
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
|
||||
limit=5)
|
||||
if last_payments_models:
|
||||
stats_text_parts.append(
|
||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments:
|
||||
status_emoji = "✅" if payment['status'] == 'succeeded' else (
|
||||
"⏳" if payment['status'] == 'pending' else "❌")
|
||||
user_info = f"User {payment['user_id']}" + (
|
||||
f" (@{payment['username']})" if payment['username'] else "")
|
||||
payment_date_str = payment['created_at']
|
||||
if isinstance(payment_date_str,
|
||||
str) and len(payment_date_str) > 10:
|
||||
payment_date_str = payment_date_str[:10]
|
||||
for payment in last_payments_models:
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status == 'pending'
|
||||
or payment.status == '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_str = payment.created_at.strftime(
|
||||
'%Y-%m-%d') if payment.created_at else "N/A"
|
||||
|
||||
stats_text_parts.append(
|
||||
_("admin_stats_payment_item",
|
||||
status_emoji=status_emoji,
|
||||
amount=payment['amount'],
|
||||
currency=payment['currency'],
|
||||
amount=payment.amount,
|
||||
currency=payment.currency,
|
||||
user_info=user_info,
|
||||
p_status=payment['status'],
|
||||
p_status=payment.status,
|
||||
p_date=payment_date_str))
|
||||
else:
|
||||
stats_text_parts.append(f"\n{_('admin_stats_no_payments_found')}")
|
||||
|
||||
sync_status = await get_last_sync_status()
|
||||
if sync_status:
|
||||
sync_status_model: Optional[
|
||||
PanelSyncStatus] = await panel_sync_dal.get_panel_sync_status(session)
|
||||
if sync_status_model and sync_status_model.status != "never_run":
|
||||
stats_text_parts.append(
|
||||
f"\n<b>{_('admin_stats_last_sync_header')}</b>")
|
||||
|
||||
sync_time_val = sync_status['last_sync_time']
|
||||
sync_time_str = "N/A"
|
||||
if isinstance(sync_time_val, datetime):
|
||||
sync_time_str = sync_time_val.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
elif isinstance(sync_time_val, str):
|
||||
sync_time_str = sync_time_val[:19] if len(
|
||||
sync_time_val) > 19 else sync_time_val
|
||||
sync_time_val = sync_status_model.last_sync_time
|
||||
sync_time_str = sync_time_val.strftime(
|
||||
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
|
||||
|
||||
details_val = sync_status['details']
|
||||
details_val = sync_status_model.details
|
||||
details_str = (details_val[:100] +
|
||||
"...") if details_val and len(details_val) > 100 else (
|
||||
details_val or "N/A")
|
||||
@@ -80,12 +86,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_time')}: {sync_time_str}")
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_status')}: {sync_status['status']}")
|
||||
f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}"
|
||||
f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
|
||||
)
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
@@ -94,26 +100,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
|
||||
final_text = "\n".join(stats_text_parts)
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"Error editing message for statistics: {e}",
|
||||
exc_info=True)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
final_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
except Exception as e_edit:
|
||||
logging.error(f"Error editing message for statistics: {e_edit}",
|
||||
exc_info=True)
|
||||
|
||||
for chunk in [
|
||||
final_text[i:i + 4000]
|
||||
for i in range(0, len(final_text), 4000)
|
||||
]:
|
||||
max_chunk_size = 4000
|
||||
for i in range(0, len(final_text), max_chunk_size):
|
||||
chunk = final_text[i:i + max_chunk_size]
|
||||
is_last_chunk = (i + max_chunk_size) >= len(final_text)
|
||||
try:
|
||||
await callback.message.answer(
|
||||
chunk,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n)
|
||||
if chunk == final_text[-len(chunk):] else None,
|
||||
current_lang, i18n) if is_last_chunk else None,
|
||||
parse_mode="HTML")
|
||||
else:
|
||||
logging.error("Cannot send statistics, callback.message is None.")
|
||||
except Exception as e_chunk:
|
||||
logging.error(f"Failed to send statistics chunk: {e_chunk}")
|
||||
if i == 0:
|
||||
await callback.message.answer(
|
||||
_("error_displaying_statistics"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
break
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
import logging
|
||||
from aiogram import Router, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
from db.database import update_sync_status, get_last_sync_status, sync_panel_user_data
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def sync_command_handler(message: types.Message, bot: Bot,
|
||||
settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
async def sync_command_handler(message_event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
bot: Bot, settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await message.answer("Language error.")
|
||||
logging.error("i18n missing in sync_command_handler")
|
||||
|
||||
if isinstance(message_event, types.Message):
|
||||
await message_event.answer("Language error.")
|
||||
elif isinstance(message_event, types.CallbackQuery):
|
||||
await message_event.answer("Language error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await message.answer(_("sync_started"))
|
||||
logging.info("Admin triggered panel sync.")
|
||||
target_chat_id = message_event.chat.id if isinstance(
|
||||
message_event, types.Message) else (
|
||||
message_event.message.chat.id if message_event.message else None)
|
||||
if not target_chat_id:
|
||||
logging.error("Sync handler: could not determine target_chat_id.")
|
||||
if isinstance(message_event, types.CallbackQuery):
|
||||
await message_event.answer("Error initiating sync.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(message_event, types.Message):
|
||||
await message_event.answer(_("sync_started"))
|
||||
|
||||
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
|
||||
|
||||
users_processed_count = 0
|
||||
users_synced_successfully = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
try:
|
||||
@@ -39,14 +59,20 @@ async def sync_command_handler(message: types.Message, bot: Bot,
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await update_sync_status("failed", error_msg)
|
||||
await message.answer(_("sync_failed", details=error_msg))
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_msg)
|
||||
await session.commit()
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_msg))
|
||||
return
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await update_sync_status("success", status_msg, 0, 0)
|
||||
await message.answer(
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0)
|
||||
await session.commit()
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_completed", status="Success", details=status_msg))
|
||||
return
|
||||
|
||||
@@ -55,94 +81,233 @@ async def sync_command_handler(message: types.Message, bot: Bot,
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
users_processed_count += 1
|
||||
telegram_id_from_panel = panel_user_dict.get('telegramId')
|
||||
panel_uuid = panel_user_dict.get('uuid')
|
||||
|
||||
if not telegram_id_from_panel:
|
||||
logging.info(
|
||||
f"Panel user {panel_uuid} (username: {panel_user_dict.get('username')}) has no 'telegramId'. Skipping TG ID based sync."
|
||||
)
|
||||
|
||||
continue
|
||||
telegram_id_from_panel_str = panel_user_dict.get('telegramId')
|
||||
panel_username = panel_user_dict.get('username')
|
||||
|
||||
if not panel_uuid:
|
||||
logging.warning(
|
||||
f"Panel user (TG ID: {telegram_id_from_panel}) missing 'uuid'. Skipping."
|
||||
f"Sync: Panel user data missing 'uuid'. Data: {str(panel_user_dict)[:200]}. Skipping."
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Panel user data for TG ID {telegram_id_from_panel} missing UUID."
|
||||
f"Panel user data (username: {panel_username or 'N/A'}) missing UUID."
|
||||
)
|
||||
continue
|
||||
|
||||
if await sync_panel_user_data(panel_user_dict):
|
||||
users_synced_successfully += 1
|
||||
else:
|
||||
sync_errors.append(
|
||||
f"Sync issue for panel user: {panel_uuid} (TG ID: {telegram_id_from_panel})"
|
||||
telegram_id_from_panel: Optional[int] = None
|
||||
if telegram_id_from_panel_str:
|
||||
try:
|
||||
telegram_id_from_panel = int(telegram_id_from_panel_str)
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has invalid 'telegramId': {telegram_id_from_panel_str}. Skipping TG ID based sync."
|
||||
)
|
||||
|
||||
if not telegram_id_from_panel:
|
||||
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no valid 'telegramId'. Skipping full sync for this user."
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
bot_user = await user_dal.get_user_by_id(session,
|
||||
telegram_id_from_panel)
|
||||
if not bot_user:
|
||||
user_data_to_create = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"username":
|
||||
panel_username,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"language_code":
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"registration_date":
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict['createdAt'].replace("Z", "+00:00"))
|
||||
if panel_user_dict.get('createdAt') else datetime.now(
|
||||
timezone.utc)
|
||||
}
|
||||
bot_user = await user_dal.create_user(session,
|
||||
user_data_to_create)
|
||||
logging.info(
|
||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
||||
)
|
||||
else:
|
||||
if bot_user.panel_user_uuid != panel_uuid:
|
||||
if bot_user.panel_user_uuid is not None:
|
||||
logging.warning(
|
||||
f"Sync: Local user {telegram_id_from_panel} was linked to {bot_user.panel_user_uuid}, panel now gives {panel_uuid}. Updating."
|
||||
)
|
||||
|
||||
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid)
|
||||
if conflicting_user and conflicting_user.user_id != telegram_id_from_panel:
|
||||
sync_errors.append(
|
||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
||||
)
|
||||
logging.error(sync_errors[-1])
|
||||
continue
|
||||
|
||||
await user_dal.update_user(
|
||||
session, telegram_id_from_panel, {
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"username": panel_username
|
||||
})
|
||||
logging.info(
|
||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_dict.get(
|
||||
'subscriptionUuid') or panel_user_dict.get('shortUuid')
|
||||
if panel_sub_link_id:
|
||||
end_date_str = panel_user_dict.get('expireAt')
|
||||
start_date_str = panel_user_dict.get('createdAt')
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_str.replace("Z", "+00:00"))
|
||||
start_date_obj = datetime.fromisoformat(
|
||||
start_date_str.replace(
|
||||
"Z",
|
||||
"+00:00")) if start_date_str else datetime.now(
|
||||
timezone.utc)
|
||||
|
||||
status_from_panel = panel_user_dict.get(
|
||||
'status', 'UNKNOWN').upper()
|
||||
is_active_flag = 1 if status_from_panel == 'ACTIVE' and end_date_obj > datetime.now(
|
||||
timezone.utc) else 0
|
||||
|
||||
sub_payload = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"panel_subscription_uuid":
|
||||
panel_sub_link_id,
|
||||
"start_date":
|
||||
start_date_obj,
|
||||
"end_date":
|
||||
end_date_obj,
|
||||
"is_active":
|
||||
is_active_flag,
|
||||
"status_from_panel":
|
||||
status_from_panel,
|
||||
"traffic_limit_bytes":
|
||||
panel_user_dict.get('trafficLimitBytes'),
|
||||
"traffic_used_bytes":
|
||||
panel_user_dict.get('usedTrafficBytes')
|
||||
}
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_link_id)
|
||||
await subscription_dal.upsert_subscription(
|
||||
session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
users_synced_successfully += 1
|
||||
except ValueError as e_date:
|
||||
logging.warning(
|
||||
f"Sync: Bad date format for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}). Sub data: {str(panel_user_dict)[:100]}. Error: {e_date}"
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Bad date for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
except Exception as e_sub_sync:
|
||||
logging.error(
|
||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
||||
exc_info=True)
|
||||
sync_errors.append(
|
||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has sub link but no expireAt date. Skipping subscription sync."
|
||||
)
|
||||
else:
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, None)
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
|
||||
)
|
||||
users_synced_successfully += 1
|
||||
|
||||
if users_processed_count % 20 == 0:
|
||||
logging.info(
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed."
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
|
||||
)
|
||||
|
||||
status_msg = f"Panel users checked: {total_panel_users}. Users/Subscriptions synced via TG ID: {users_synced_successfully}."
|
||||
if sync_errors:
|
||||
status_msg += f" Errors encountered: {len(sync_errors)}. See logs for details."
|
||||
error_preview = "\n".join(sync_errors[:3])
|
||||
await update_sync_status("partial_success",
|
||||
status_msg + " " + error_preview,
|
||||
total_panel_users,
|
||||
users_synced_successfully)
|
||||
await message.answer(
|
||||
_("sync_completed",
|
||||
status="Partial Success",
|
||||
details=status_msg))
|
||||
else:
|
||||
await update_sync_status("success", status_msg, total_panel_users,
|
||||
users_synced_successfully)
|
||||
await message.answer(
|
||||
_("sync_completed", status="Success", details=status_msg))
|
||||
status_msg_key = "sync_completed_details"
|
||||
final_status_type = "success"
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during /sync command: {e}", exc_info=True)
|
||||
error_detail = f"An unexpected error occurred during sync: {str(e)}"
|
||||
await update_sync_status("failed", error_detail, users_processed_count,
|
||||
users_synced_successfully)
|
||||
await message.answer(_("sync_failed", details=error_detail))
|
||||
if sync_errors:
|
||||
final_status_type = "partial_success"
|
||||
status_msg_key = "sync_completed_with_errors_details"
|
||||
error_preview = "\n".join(sync_errors[:3])
|
||||
details_for_db = f"Users processed: {users_processed_count}. Subs synced: {subscriptions_synced_count}. Errors: {len(sync_errors)}. First few: {error_preview}"
|
||||
else:
|
||||
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, final_status_type, details_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
await session.commit()
|
||||
|
||||
final_user_message = _(status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=error_preview
|
||||
if sync_errors else _("no_errors_placeholder"))
|
||||
await bot.send_message(target_chat_id, final_user_message)
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}",
|
||||
exc_info=True)
|
||||
error_detail_for_db = f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_detail_for_db))
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.answer("Language error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
status_record = await get_last_sync_status()
|
||||
|
||||
status_record_model = await panel_sync_dal.get_panel_sync_status(session)
|
||||
response_text = ""
|
||||
if status_record:
|
||||
last_time_val = status_record['last_sync_time']
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = last_time_val.strftime(
|
||||
'%Y-%m-%d %H:%M:%S UTC') if isinstance(
|
||||
last_time_val, datetime) else str(last_time_val)
|
||||
if isinstance(last_time_str, str) and len(last_time_str) > 19:
|
||||
last_time_str = last_time_str[:19]
|
||||
details_val = status_record['details']
|
||||
'%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A"
|
||||
|
||||
details_val = status_record_model.details
|
||||
details_str = (details_val[:200] +
|
||||
"...") if details_val and len(details_val) > 200 else (
|
||||
details_val or "N/A")
|
||||
|
||||
response_text = (
|
||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
f" {_('admin_stats_sync_time')}: {last_time_str}\n"
|
||||
f" {_('admin_stats_sync_status')}: {status_record['status']}\n"
|
||||
f" {_('admin_stats_sync_users_processed')}: {status_record['users_processed_from_panel']}\n"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {status_record['subscriptions_synced']}\n"
|
||||
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
|
||||
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import logging
|
||||
import re
|
||||
import math
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Tuple, List, Any
|
||||
import math
|
||||
import aiosqlite
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import (get_user, set_user_ban_status_db,
|
||||
get_user_by_telegram_username,
|
||||
get_banned_users_list_paginated,
|
||||
get_user_active_subscription_end_date)
|
||||
|
||||
from db.dal import user_dal, subscription_dal
|
||||
from db.models import User, Subscription
|
||||
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_back_to_admin_panel_keyboard, get_user_card_keyboard,
|
||||
@@ -21,35 +23,49 @@ from bot.keyboards.inline.admin_keyboards import (
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_user_management_router")
|
||||
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
|
||||
async def _get_user_model_by_input(session: AsyncSession,
|
||||
input_text: str) -> Optional[User]:
|
||||
|
||||
user_model: Optional[User] = None
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session,
|
||||
int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(
|
||||
session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
return user_model
|
||||
|
||||
|
||||
async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing ban prompt.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _("admin_ban_user_prompt")
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
except Exception as e:
|
||||
logging.warning(f"Edit failed: {e}")
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
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"Edit failed for ban_user_prompt: {e}. Sending new message.")
|
||||
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_user_id_to_ban)
|
||||
|
||||
@@ -58,103 +74,90 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
async def process_user_input_to_ban_handler(message: types.Message,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await message.reply("Language error.")
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
input_text = message.text.strip()
|
||||
user_to_ban_data: Optional[aiosqlite.Row] = None
|
||||
user_id_or_username_for_msg = input_text
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_id_to_ban_val = int(input_text)
|
||||
user_to_ban_data = await get_user(user_id_to_ban_val)
|
||||
except ValueError:
|
||||
await message.answer(_("admin_invalid_user_id_format"))
|
||||
return
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_to_ban_data = await get_user_by_telegram_username(input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_to_ban_data = await get_user_by_telegram_username(input_text)
|
||||
else:
|
||||
await message.answer(
|
||||
_("admin_invalid_user_id_format") + " " +
|
||||
_("admin_invalid_username_format"))
|
||||
return
|
||||
if not user_to_ban_data:
|
||||
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_to_ban_model = await _get_user_model_by_input(session, input_text)
|
||||
|
||||
if not user_to_ban_model:
|
||||
await message.answer(
|
||||
_("admin_user_not_found_in_bot_db", user_id=input_text))
|
||||
await state.clear()
|
||||
return
|
||||
user_id_to_ban = user_to_ban_data['user_id']
|
||||
user_id_or_username_for_msg = f"@{user_to_ban_data['username']}" if user_to_ban_data.get(
|
||||
'username') else str(user_id_to_ban)
|
||||
if user_id_to_ban == message.from_user.id or user_id_to_ban in settings.ADMIN_IDS:
|
||||
|
||||
user_id_to_ban = user_to_ban_model.user_id
|
||||
user_display_for_msg = user_to_ban_model.username or str(user_id_to_ban)
|
||||
|
||||
if message.from_user and (user_id_to_ban == message.from_user.id
|
||||
or user_id_to_ban in settings.ADMIN_IDS):
|
||||
await message.answer(_("admin_cannot_ban_self_or_admin"))
|
||||
await state.clear()
|
||||
return
|
||||
if user_to_ban_data['is_banned']:
|
||||
|
||||
if user_to_ban_model.is_banned:
|
||||
await message.answer(
|
||||
_("admin_user_already_banned",
|
||||
user_id_or_username=user_id_or_username_for_msg))
|
||||
user_id_or_username=user_display_for_msg))
|
||||
await state.clear()
|
||||
return
|
||||
panel_user_uuid = user_to_ban_data[
|
||||
'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys(
|
||||
) and user_to_ban_data['panel_user_uuid'] else None
|
||||
await set_user_ban_status_db(user_id_to_ban, is_banned=True)
|
||||
reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||
if panel_user_uuid:
|
||||
panel_ban_success = await panel_service.update_user_status_on_panel(
|
||||
panel_user_uuid, enable=False)
|
||||
if panel_ban_success:
|
||||
await message.answer(_(
|
||||
"admin_user_banned_success_panel_too",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
else:
|
||||
await message.answer(_(
|
||||
"admin_user_banned_local_panel_fail",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
|
||||
ban_success_local = await user_dal.set_user_ban_status(
|
||||
session, user_id_to_ban, True)
|
||||
|
||||
if ban_success_local:
|
||||
|
||||
panel_ban_message_part = ""
|
||||
if user_to_ban_model.panel_user_uuid:
|
||||
panel_ban_api_success = await panel_service.update_user_status_on_panel(
|
||||
user_to_ban_model.panel_user_uuid, enable=False)
|
||||
if panel_ban_api_success:
|
||||
panel_ban_message_part = _("admin_panel_ban_success_part")
|
||||
else:
|
||||
panel_ban_message_part = _("admin_panel_ban_fail_part")
|
||||
|
||||
await session.commit()
|
||||
await message.answer(_("admin_user_banned_success_combined",
|
||||
user_id_or_username=user_display_for_msg,
|
||||
panel_status_part=panel_ban_message_part),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
else:
|
||||
await message.answer(_(
|
||||
"admin_user_banned_local_no_panel_uuid",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_user_ban_failed_local_db_error"))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def unban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing unban prompt.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
prompt_text = _("admin_unban_user_prompt")
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
except:
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
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"Edit failed for unban_user_prompt: {e}. Sending new message.")
|
||||
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_user_id_to_unban)
|
||||
|
||||
@@ -164,131 +167,168 @@ async def process_user_input_to_unban_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await message.reply("Language error.")
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
input_text = message.text.strip()
|
||||
user_to_unban_data: Optional[aiosqlite.Row] = None
|
||||
user_id_or_username_for_msg = input_text
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_id_to_unban_val = int(input_text)
|
||||
user_to_unban_data = await get_user(user_id_to_unban_val)
|
||||
except ValueError:
|
||||
await message.answer(_("admin_invalid_user_id_format"))
|
||||
return
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_to_unban_data = await get_user_by_telegram_username(input_text[1:]
|
||||
)
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_to_unban_data = await get_user_by_telegram_username(input_text)
|
||||
else:
|
||||
await message.answer(
|
||||
_("admin_invalid_user_id_format") + " " +
|
||||
_("admin_invalid_username_format"))
|
||||
return
|
||||
if not user_to_unban_data:
|
||||
|
||||
input_text = message.text.strip() if message.text else ""
|
||||
user_to_unban_model = await _get_user_model_by_input(session, input_text)
|
||||
|
||||
if not user_to_unban_model:
|
||||
await message.answer(
|
||||
_("admin_user_not_found_in_bot_db", user_id=input_text))
|
||||
await state.clear()
|
||||
return
|
||||
user_id_to_unban = user_to_unban_data['user_id']
|
||||
user_id_or_username_for_msg = f"@{user_to_unban_data['username']}" if user_to_unban_data.get(
|
||||
'username') else str(user_id_to_unban)
|
||||
if not user_to_unban_data['is_banned']:
|
||||
|
||||
user_id_to_unban = user_to_unban_model.user_id
|
||||
user_display_for_msg = user_to_unban_model.username or str(
|
||||
user_id_to_unban)
|
||||
|
||||
if not user_to_unban_model.is_banned:
|
||||
await message.answer(
|
||||
_("admin_user_not_banned",
|
||||
user_id_or_username=user_id_or_username_for_msg))
|
||||
user_id_or_username=user_display_for_msg))
|
||||
await state.clear()
|
||||
return
|
||||
panel_user_uuid = user_to_unban_data[
|
||||
'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys(
|
||||
) and user_to_unban_data['panel_user_uuid'] else None
|
||||
await set_user_ban_status_db(user_id_to_unban, is_banned=False)
|
||||
reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||
if panel_user_uuid:
|
||||
panel_unban_success = await panel_service.update_user_status_on_panel(
|
||||
panel_user_uuid, enable=True)
|
||||
if panel_unban_success:
|
||||
await message.answer(_(
|
||||
"admin_user_unbanned_success_panel_too",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
else:
|
||||
await message.answer(_(
|
||||
"admin_user_unbanned_local_panel_fail",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
|
||||
unban_success_local = await user_dal.set_user_ban_status(
|
||||
session, user_id_to_unban, False)
|
||||
|
||||
if unban_success_local:
|
||||
panel_unban_message_part = ""
|
||||
if user_to_unban_model.panel_user_uuid:
|
||||
panel_unban_api_success = await panel_service.update_user_status_on_panel(
|
||||
user_to_unban_model.panel_user_uuid, enable=True)
|
||||
if panel_unban_api_success:
|
||||
panel_unban_message_part = _("admin_panel_unban_success_part")
|
||||
else:
|
||||
panel_unban_message_part = _("admin_panel_unban_fail_part")
|
||||
|
||||
await session.commit()
|
||||
await message.answer(_("admin_user_unbanned_success_combined",
|
||||
user_id_or_username=user_display_for_msg,
|
||||
panel_status_part=panel_unban_message_part),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
else:
|
||||
await message.answer(_(
|
||||
"admin_user_unbanned_local_no_panel_uuid",
|
||||
user_id_or_username=user_id_or_username_for_msg),
|
||||
reply_markup=reply_markup_val)
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_user_unban_failed_local_db_error"))
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
async def view_banned_users_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
state: FSMContext):
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
await state.clear()
|
||||
current_page = 0
|
||||
current_page_idx = 0
|
||||
if ":" in callback.data and callback.data.count(":") == 2:
|
||||
try:
|
||||
current_page = int(callback.data.split(":")[-1])
|
||||
current_page_idx = int(callback.data.split(":")[-1])
|
||||
except ValueError:
|
||||
current_page = 0
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
current_page_idx = 0
|
||||
|
||||
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)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying banned users.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
banned_users, total_banned = await get_banned_users_list_paginated(
|
||||
limit=settings.LOGS_PAGE_SIZE,
|
||||
offset=current_page * settings.LOGS_PAGE_SIZE)
|
||||
page_size = settings.LOGS_PAGE_SIZE
|
||||
offset = current_page_idx * page_size
|
||||
|
||||
if not callback.message:
|
||||
await callback.answer("Error.")
|
||||
return
|
||||
if total_banned == 0:
|
||||
banned_user_models, total_banned_count = await user_dal.get_banned_users_paginated(
|
||||
session, limit=page_size, offset=offset)
|
||||
|
||||
if total_banned_count == 0:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_banned_users"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
else:
|
||||
total_pages = math.ceil(
|
||||
total_banned /
|
||||
settings.LOGS_PAGE_SIZE) if settings.LOGS_PAGE_SIZE > 0 else 1
|
||||
total_pages = math.ceil(total_banned_count /
|
||||
page_size) if page_size > 0 else 1
|
||||
await callback.message.edit_text(
|
||||
text=_("admin_banned_list_title",
|
||||
current_page=current_page + 1,
|
||||
current_page=current_page_idx + 1,
|
||||
total_pages=max(1, total_pages)),
|
||||
reply_markup=get_banned_users_keyboard(banned_users, current_page,
|
||||
total_banned, i18n,
|
||||
reply_markup=get_banned_users_keyboard(banned_user_models,
|
||||
current_page_idx,
|
||||
total_banned_count, i18n,
|
||||
current_lang, settings))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _show_user_card_actual(target_message: types.Message,
|
||||
user_id_to_show: int,
|
||||
banned_list_page_to_return: int,
|
||||
i18n_data: dict, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n: return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
user_model = await user_dal.get_user_by_id(session, user_id_to_show)
|
||||
if not user_model:
|
||||
await target_message.edit_text(
|
||||
_("admin_user_not_found_in_bot_db", user_id=user_id_to_show),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
return
|
||||
|
||||
user_display_name = user_model.first_name or (f"@{user_model.username}"
|
||||
if user_model.username else
|
||||
f"ID: {user_id_to_show}")
|
||||
|
||||
sub_end_date_str = await subscription_dal.get_user_active_subscription_end_date_str(
|
||||
session, user_id_to_show) or _("user_card_sub_na")
|
||||
|
||||
reg_date_display = user_model.registration_date.strftime(
|
||||
'%Y-%m-%d %H:%M') if user_model.registration_date else "N/A"
|
||||
|
||||
card_text = _("user_card_info",
|
||||
user_id=user_model.user_id,
|
||||
username=user_model.username or "N/A",
|
||||
first_name=user_model.first_name or "",
|
||||
last_name=user_model.last_name or "",
|
||||
language_code=user_model.language_code or "N/A",
|
||||
panel_user_uuid=user_model.panel_user_uuid or "N/A",
|
||||
ban_status=_("user_card_banned")
|
||||
if user_model.is_banned else _("user_card_active"),
|
||||
reg_date=reg_date_display,
|
||||
sub_end_date=sub_end_date_str)
|
||||
await target_message.edit_text(
|
||||
text=
|
||||
f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",
|
||||
reply_markup=get_user_card_keyboard(user_id_to_show,
|
||||
bool(user_model.is_banned), i18n,
|
||||
current_lang,
|
||||
banned_list_page_to_return),
|
||||
parse_mode="HTML")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_user_card:"))
|
||||
async def show_user_card_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
state: FSMContext,
|
||||
session: AsyncSession,
|
||||
force_user_id: Optional[int] = None,
|
||||
force_page: Optional[int] = None):
|
||||
|
||||
await state.clear()
|
||||
|
||||
user_id_to_show = 0
|
||||
banned_list_page_to_return = 0
|
||||
|
||||
if force_user_id is not None and force_page is not None:
|
||||
user_id_to_show = force_user_id
|
||||
banned_list_page_to_return = force_page
|
||||
@@ -300,281 +340,210 @@ async def show_user_card_handler(callback: types.CallbackQuery,
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid user card data.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying user card.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
user_data = await get_user(user_id_to_show)
|
||||
if not callback.message:
|
||||
await callback.answer("Error.")
|
||||
return
|
||||
if not user_data:
|
||||
await callback.message.edit_text(
|
||||
_("admin_user_not_found_in_bot_db", user_id=user_id_to_show),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
|
||||
await _show_user_card_actual(
|
||||
target_message=callback.message,
|
||||
user_id_to_show=user_id_to_show,
|
||||
banned_list_page_to_return=banned_list_page_to_return,
|
||||
i18n_data=i18n_data,
|
||||
settings=settings,
|
||||
panel_service=panel_service,
|
||||
session=session)
|
||||
if force_user_id is None:
|
||||
await callback.answer()
|
||||
return
|
||||
user_display_name = user_data['first_name'] or (
|
||||
f"@{user_data['username']}"
|
||||
if user_data.get('username') else f"ID: {user_id_to_show}")
|
||||
sub_end_date_str = await get_user_active_subscription_end_date(
|
||||
user_id_to_show) or _("user_card_sub_na")
|
||||
reg_date_from_db = user_data[
|
||||
'registration_date_str'] if 'registration_date_str' in user_data.keys(
|
||||
) else "N/A"
|
||||
reg_date_display = reg_date_from_db[:
|
||||
10] if reg_date_from_db and reg_date_from_db != "N/A" else "N/A"
|
||||
card_text = _(
|
||||
"user_card_info",
|
||||
user_id=user_data['user_id'],
|
||||
username=user_data.get('username', "N/A"),
|
||||
first_name=user_data.get('first_name', ""),
|
||||
last_name=user_data.get('last_name', ""),
|
||||
language_code=user_data.get('language_code', "N/A"),
|
||||
panel_user_uuid=user_data.get('panel_user_uuid', "N/A"),
|
||||
ban_status=_(key="user_card_banned") if user_data['is_banned'] else _(
|
||||
key="user_card_active"),
|
||||
reg_date=reg_date_display,
|
||||
sub_end_date=sub_end_date_str)
|
||||
await callback.message.edit_text(
|
||||
text=
|
||||
f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",
|
||||
reply_markup=get_user_card_keyboard(user_id_to_show,
|
||||
bool(user_data['is_banned']), i18n,
|
||||
current_lang,
|
||||
banned_list_page_to_return),
|
||||
parse_mode="HTML")
|
||||
if force_user_id is None: await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_unban_confirm:"))
|
||||
async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings):
|
||||
|
||||
async def _confirm_action_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession, action_type: str):
|
||||
try:
|
||||
|
||||
_, user_id_str, page_str = callback.data.split(":")
|
||||
user_id = int(user_id_str)
|
||||
banned_list_page = int(page_str)
|
||||
except:
|
||||
await callback.answer("Invalid data", show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Invalid confirmation data.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
|
||||
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)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing confirmation.",
|
||||
show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
user_data = await get_user(user_id)
|
||||
user_display = (user_data['first_name'] or
|
||||
(f"@{user_data['username']}" if user_data.get('username')
|
||||
else f"ID {user_id}")) if user_data else f"ID {user_id}"
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
text=_("admin_confirm_unban_prompt",
|
||||
user_display=user_display,
|
||||
user_id=user_id),
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
yes_callback_data=
|
||||
f"admin_unban_do:{user_id}:{banned_list_page}",
|
||||
no_callback_data=
|
||||
f"admin_user_card:{user_id}:{banned_list_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang),
|
||||
parse_mode="HTML")
|
||||
|
||||
user_model = await user_dal.get_user_by_id(session, user_id)
|
||||
user_display = (user_model.first_name or
|
||||
(f"@{user_model.username}" if user_model.username else
|
||||
f"ID {user_id}")) if user_model else f"ID {user_id}"
|
||||
|
||||
prompt_key = f"admin_confirm_{action_type}_prompt"
|
||||
yes_callback = f"admin_{action_type}_do:{user_id}:{banned_list_page}"
|
||||
no_callback = f"admin_user_card:{user_id}:{banned_list_page}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_("admin_confirm_action_title",
|
||||
action_text=_(f"{action_type}_verb_l")) + "\n\n" +
|
||||
_(prompt_key, user_display=user_display, user_id=user_id),
|
||||
reply_markup=get_confirmation_keyboard(yes_callback_data=yes_callback,
|
||||
no_callback_data=no_callback,
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ban_confirm:"))
|
||||
async def confirm_ban_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings):
|
||||
settings: Settings, session: AsyncSession):
|
||||
await _confirm_action_handler(callback, i18n_data, settings, session,
|
||||
"ban")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_unban_confirm:"))
|
||||
async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
await _confirm_action_handler(callback, i18n_data, settings, session,
|
||||
"unban")
|
||||
|
||||
|
||||
async def _do_ban_unban_action_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
state: FSMContext, action_type: str):
|
||||
try:
|
||||
|
||||
_, user_id_str, page_str = callback.data.split(":")
|
||||
user_id = int(user_id_str)
|
||||
user_id_target = int(user_id_str)
|
||||
banned_list_page = int(page_str)
|
||||
except:
|
||||
await callback.answer("Invalid data", show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Invalid action data.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
|
||||
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)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing action.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
user_data = await get_user(user_id)
|
||||
user_display = (user_data['first_name'] or
|
||||
(f"@{user_data['username']}" if user_data.get('username')
|
||||
else f"ID {user_id}")) if user_data else f"ID {user_id}"
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
text=_("admin_confirm_ban_prompt",
|
||||
user_display=user_display,
|
||||
user_id=user_id),
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ban_do:{user_id}:{banned_list_page}",
|
||||
no_callback_data=
|
||||
f"admin_user_card:{user_id}:{banned_list_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_unban_do:"))
|
||||
async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
state: FSMContext):
|
||||
|
||||
try:
|
||||
_, user_id_str, page_str = callback.data.split(":")
|
||||
user_id_to_unban = int(user_id_str)
|
||||
banned_list_page = int(page_str)
|
||||
except:
|
||||
await callback.answer("Invalid data", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
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)
|
||||
user_to_unban_data = await get_user(user_id_to_unban)
|
||||
user_display_name = (
|
||||
user_to_unban_data['first_name'] or
|
||||
(f"@{user_to_unban_data['username']}"
|
||||
if user_to_unban_data.get('username') else f"ID {user_id_to_unban}")
|
||||
) if user_to_unban_data else f"ID {user_id_to_unban}"
|
||||
if not user_to_unban_data or not user_to_unban_data['is_banned']:
|
||||
await callback.answer(_("admin_user_not_banned",
|
||||
user_id_or_username=user_display_name),
|
||||
user_model = await user_dal.get_user_by_id(session, user_id_target)
|
||||
if not user_model:
|
||||
await callback.answer(_("admin_user_not_found_in_bot_db",
|
||||
user_id=user_id_target),
|
||||
show_alert=True)
|
||||
if callback.message:
|
||||
await show_user_card_handler(callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
panel_service,
|
||||
state,
|
||||
force_user_id=user_id_to_unban,
|
||||
force_page=banned_list_page)
|
||||
return
|
||||
await set_user_ban_status_db(user_id_to_unban, is_banned=False)
|
||||
panel_user_uuid = user_to_unban_data[
|
||||
'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys(
|
||||
) and user_to_unban_data['panel_user_uuid'] else None
|
||||
if panel_user_uuid:
|
||||
if not await panel_service.update_user_status_on_panel(panel_user_uuid,
|
||||
enable=True):
|
||||
logging.warning(
|
||||
f"Panel status update fail for unban {user_id_to_unban}")
|
||||
await callback.answer(_("admin_user_unbanned_from_card",
|
||||
user_display=user_display_name,
|
||||
user_id=user_id_to_unban),
|
||||
show_alert=False)
|
||||
if callback.message:
|
||||
await show_user_card_handler(callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
panel_service,
|
||||
state,
|
||||
force_user_id=user_id_to_unban,
|
||||
force_page=banned_list_page)
|
||||
|
||||
user_display_name = user_model.first_name or (f"@{user_model.username}"
|
||||
if user_model.username else
|
||||
f"ID {user_id_target}")
|
||||
|
||||
is_banning = action_type == "ban"
|
||||
|
||||
if is_banning:
|
||||
if user_model.is_banned:
|
||||
await callback.answer(_("admin_user_already_banned",
|
||||
user_id_or_username=user_display_name),
|
||||
show_alert=True)
|
||||
return
|
||||
if user_id_target == callback.from_user.id or user_id_target in settings.ADMIN_IDS:
|
||||
await callback.answer(_("admin_cannot_ban_self_or_admin"),
|
||||
show_alert=True)
|
||||
return
|
||||
else:
|
||||
if not user_model.is_banned:
|
||||
await callback.answer(_("admin_user_not_banned",
|
||||
user_id_or_username=user_display_name),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
action_success_local = await user_dal.set_user_ban_status(
|
||||
session, user_id_target, is_banning)
|
||||
|
||||
if action_success_local:
|
||||
panel_action_message = ""
|
||||
if user_model.panel_user_uuid:
|
||||
panel_api_success = await panel_service.update_user_status_on_panel(
|
||||
user_model.panel_user_uuid, enable=not is_banning)
|
||||
if not panel_api_success:
|
||||
panel_action_message = _("admin_panel_status_update_fail_part")
|
||||
logging.warning(
|
||||
f"Panel status update failed for {action_type} of user {user_id_target} (panel: {user_model.panel_user_uuid})"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
alert_message_key = f"admin_user_{action_type}ned_from_card_alert"
|
||||
await callback.answer(_(alert_message_key,
|
||||
user_display=user_display_name,
|
||||
user_id=user_id_target) + " " +
|
||||
panel_action_message,
|
||||
show_alert=False)
|
||||
else:
|
||||
await session.rollback()
|
||||
await callback.answer(_(f"admin_user_{action_type}_failed_db_error"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
await show_user_card_handler(callback,
|
||||
state,
|
||||
i18n_data,
|
||||
settings,
|
||||
panel_service,
|
||||
session,
|
||||
force_user_id=user_id_target,
|
||||
force_page=banned_list_page)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ban_do:"))
|
||||
async def do_ban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
state: FSMContext):
|
||||
try:
|
||||
_, user_id_str, page_str = callback.data.split(":")
|
||||
user_id_to_ban = int(user_id_str)
|
||||
banned_list_page = int(page_str)
|
||||
except:
|
||||
await callback.answer("Invalid data", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
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)
|
||||
user_to_ban_data = await get_user(user_id_to_ban)
|
||||
user_display_name = (
|
||||
user_to_ban_data['first_name'] or
|
||||
(f"@{user_to_ban_data['username']}"
|
||||
if user_to_ban_data.get('username') else f"ID {user_id_to_ban}")
|
||||
) if user_to_ban_data else f"ID {user_id_to_ban}"
|
||||
if not user_to_ban_data:
|
||||
await callback.answer(_("admin_user_not_found_in_bot_db",
|
||||
user_id=user_id_to_ban),
|
||||
show_alert=True)
|
||||
return
|
||||
if user_to_ban_data['is_banned']:
|
||||
await callback.answer(_("admin_user_already_banned",
|
||||
user_id_or_username=user_display_name),
|
||||
show_alert=True)
|
||||
return
|
||||
if user_id_to_ban == callback.from_user.id or user_id_to_ban in settings.ADMIN_IDS:
|
||||
await callback.answer(_("admin_cannot_ban_self_or_admin"),
|
||||
show_alert=True)
|
||||
return
|
||||
session: AsyncSession, state: FSMContext):
|
||||
await _do_ban_unban_action_handler(callback, i18n_data, settings,
|
||||
panel_service, session, state, "ban")
|
||||
|
||||
await set_user_ban_status_db(user_id_to_ban, is_banned=True)
|
||||
panel_user_uuid = user_to_ban_data[
|
||||
'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys(
|
||||
) and user_to_ban_data['panel_user_uuid'] else None
|
||||
if panel_user_uuid:
|
||||
if not await panel_service.update_user_status_on_panel(panel_user_uuid,
|
||||
enable=False):
|
||||
logging.warning(
|
||||
f"Panel status update fail for ban {user_id_to_ban}")
|
||||
await callback.answer(_("admin_user_banned_from_card",
|
||||
user_display=user_display_name,
|
||||
user_id=user_id_to_ban),
|
||||
show_alert=False)
|
||||
if callback.message:
|
||||
await show_user_card_handler(callback,
|
||||
i18n_data,
|
||||
settings,
|
||||
panel_service,
|
||||
state,
|
||||
force_user_id=user_id_to_ban,
|
||||
force_page=banned_list_page)
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_unban_do:"))
|
||||
async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession, state: FSMContext):
|
||||
await _do_ban_unban_action_handler(callback, i18n_data, settings,
|
||||
panel_service, session, state, "unban")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_user_id_to_ban)
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_user_id_to_unban)
|
||||
async def cancel_user_management_input_state(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict, bot: Bot):
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
async def cancel_user_management_input_state(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
bot: Bot,
|
||||
):
|
||||
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)
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error cancelling.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang,
|
||||
settings))
|
||||
except:
|
||||
await callback.message.answer(
|
||||
_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang,
|
||||
settings))
|
||||
await callback.answer(_("admin_action_cancelled_default"))
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_action_cancelled_default"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
await callback.answer(_("admin_action_cancelled_default_alert"))
|
||||
await state.clear()
|
||||
|
||||
+238
-214
@@ -1,35 +1,36 @@
|
||||
import logging
|
||||
import json
|
||||
import aiosqlite
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from yookassa.domain.notification import WebhookNotification
|
||||
from yookassa.domain.models import Amount
|
||||
from yookassa.domain.models.amount import Amount as YooKassaAmount
|
||||
|
||||
from db.dal import payment_dal, user_dal
|
||||
|
||||
from db.database import get_db_connection_manager, _setup_db_connection, get_user
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.payment_service import YooKassaService
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
||||
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
||||
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
||||
YOOKASSA_EVENT_REFUND_SUCCEEDED = 'refund.succeeded'
|
||||
|
||||
|
||||
async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
|
||||
async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
payment_info_from_webhook: dict,
|
||||
i18n: JsonI18n, settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
yk_service: YooKassaService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
@@ -43,6 +44,8 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
|
||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||
)
|
||||
return
|
||||
|
||||
db_user = None
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
subscription_months = int(subscription_months_str)
|
||||
@@ -50,262 +53,283 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
|
||||
promo_code_id = int(
|
||||
promo_code_id_str
|
||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||
|
||||
amount_data = payment_info_from_webhook.get("amount", {})
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}."
|
||||
)
|
||||
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, payment_db_id, "failed_user_not_found",
|
||||
payment_info_from_webhook.get("id"))
|
||||
|
||||
return
|
||||
|
||||
except (TypeError, ValueError) as e:
|
||||
logging.error(
|
||||
f"Invalid metadata format for payment processing: {metadata} - {e}"
|
||||
)
|
||||
|
||||
if payment_db_id_str and payment_db_id_str.isdigit():
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, int(payment_db_id_str), "failed_metadata_error",
|
||||
payment_info_from_webhook.get("id"))
|
||||
except Exception as e_upd:
|
||||
logging.error(
|
||||
f"Failed to update payment status after metadata error: {e_upd}"
|
||||
)
|
||||
return
|
||||
|
||||
final_end_date_for_user: Optional[datetime] = None
|
||||
applied_referee_bonus_days: Optional[int] = None
|
||||
base_subscription_end_date: Optional[datetime] = None
|
||||
|
||||
async with get_db_connection_manager() as db:
|
||||
await _setup_db_connection(db)
|
||||
try:
|
||||
await db.execute(
|
||||
"UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
|
||||
(payment_info_from_webhook.get("status", "succeeded"),
|
||||
payment_db_id, payment_info_from_webhook.get("id")))
|
||||
|
||||
new_sub_details = await subscription_service.activate_subscription(
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
db_conn=db,
|
||||
promo_code_id=promo_code_id)
|
||||
|
||||
if new_sub_details and new_sub_details.get('end_date'):
|
||||
base_subscription_end_date = new_sub_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
user_id, subscription_months, db_conn=db)
|
||||
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
final_end_date_for_user = referral_bonus_info[
|
||||
"referee_new_end_date"]
|
||||
applied_referee_bonus_days = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days")
|
||||
|
||||
await db.commit()
|
||||
|
||||
user_lang = await subscription_service.get_user_language(
|
||||
user_id)
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
|
||||
)
|
||||
|
||||
success_message = ""
|
||||
if applied_referee_bonus_days and final_end_date_for_user:
|
||||
referee_user_data = await get_user(user_id)
|
||||
inviter_name_for_msg = _("friend_placeholder")
|
||||
|
||||
if referee_user_data and referee_user_data[
|
||||
'referred_by_id'] is not None:
|
||||
inviter_user_data_for_msg = await get_user(
|
||||
referee_user_data['referred_by_id'])
|
||||
if inviter_user_data_for_msg and inviter_user_data_for_msg[
|
||||
'first_name']:
|
||||
inviter_name_for_msg = inviter_user_data_for_msg[
|
||||
'first_name']
|
||||
|
||||
success_message = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime(
|
||||
'%Y-%m-%d')
|
||||
if base_subscription_end_date else "N/A",
|
||||
bonus_days=applied_referee_bonus_days,
|
||||
final_end_date=final_end_date_for_user.strftime(
|
||||
'%Y-%m-%d'),
|
||||
inviter_name=inviter_name_for_msg)
|
||||
elif final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id}"
|
||||
)
|
||||
success_message = _("payment_successful_error_details")
|
||||
|
||||
try:
|
||||
await bot.send_message(user_id, success_message)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send final payment success message to user {user_id}: {e}"
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}"
|
||||
)
|
||||
await db.rollback()
|
||||
except Exception as e:
|
||||
try:
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
||||
yk_payment_id=yk_payment_id_from_hook)
|
||||
if not updated_payment_record:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment transaction for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
await db.rollback()
|
||||
try:
|
||||
user_lang_for_error = await subscription_service.get_user_language(
|
||||
user_id)
|
||||
_err = lambda key, **kwargs: i18n.gettext(
|
||||
user_lang_for_error, key, **kwargs)
|
||||
await bot.send_message(user_id,
|
||||
_err("error_processing_your_payment"))
|
||||
except Exception as notify_err:
|
||||
logging.error(
|
||||
f"Failed to send error notification to user {user_id}: {notify_err}"
|
||||
)
|
||||
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"DB Error: Could not update payment record {payment_db_id}")
|
||||
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id)
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
|
||||
)
|
||||
raise Exception(
|
||||
f"Subscription Error: Failed to activate for user {user_id}")
|
||||
|
||||
base_subscription_end_date = activation_details['end_date']
|
||||
final_end_date_for_user = base_subscription_end_date
|
||||
applied_promo_bonus_days = activation_details.get(
|
||||
"applied_promo_bonus_days", 0)
|
||||
|
||||
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, subscription_months)
|
||||
applied_referee_bonus_days_from_referral: Optional[int] = None
|
||||
if referral_bonus_info and referral_bonus_info.get(
|
||||
"referee_new_end_date"):
|
||||
final_end_date_for_user = referral_bonus_info[
|
||||
"referee_new_end_date"]
|
||||
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
||||
"referee_bonus_applied_days")
|
||||
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
success_message = ""
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(
|
||||
session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
|
||||
success_message = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful_with_promo",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
elif final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
)
|
||||
success_message = _("payment_successful_error_details")
|
||||
|
||||
try:
|
||||
await bot.send_message(user_id, success_message)
|
||||
except Exception as e_notify:
|
||||
logging.error(
|
||||
f"Failed to send final payment success message to user {user_id}: {e_notify}"
|
||||
)
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
|
||||
exc_info=True)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
async def process_cancelled_payment(bot: Bot, payment_info_from_webhook: dict,
|
||||
async def process_cancelled_payment(session: AsyncSession, bot: Bot,
|
||||
payment_info_from_webhook: dict,
|
||||
i18n: JsonI18n, settings: Settings):
|
||||
|
||||
metadata = payment_info_from_webhook.get("metadata", {})
|
||||
user_id_str = metadata.get("user_id")
|
||||
payment_db_id_str = metadata.get("payment_db_id")
|
||||
|
||||
if not user_id_str or not payment_db_id_str:
|
||||
logging.warning(
|
||||
f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}"
|
||||
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
payment_db_id = int(payment_db_id_str)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid metadata in cancelled payment: {metadata}")
|
||||
return
|
||||
async with get_db_connection_manager() as db:
|
||||
await _setup_db_connection(db)
|
||||
await db.execute(
|
||||
"UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
|
||||
(payment_info_from_webhook.get("status", "canceled"),
|
||||
payment_db_id, payment_info_from_webhook.get("id")))
|
||||
await db.commit()
|
||||
user_lang = getattr(settings, 'DEFAULT_LANGUAGE', 'en')
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
try:
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send payment cancellation message to user {user_id}: {e}"
|
||||
)
|
||||
f"Invalid metadata in cancelled payment webhook: {metadata}")
|
||||
return
|
||||
|
||||
try:
|
||||
updated_payment = await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=payment_db_id,
|
||||
new_status=payment_info_from_webhook.get("status", "canceled"),
|
||||
yk_payment_id=payment_info_from_webhook.get("id"))
|
||||
|
||||
if updated_payment:
|
||||
logging.info(
|
||||
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}."
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
user_lang = settings.DEFAULT_LANGUAGE
|
||||
if db_user and db_user.language_code: user_lang = db_user.language_code
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
await bot.send_message(user_id, _("payment_failed"))
|
||||
|
||||
except Exception as e_process_cancel:
|
||||
logging.error(
|
||||
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}",
|
||||
exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
async def yookassa_webhook_route(request: web.Request):
|
||||
logging.info(
|
||||
f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}"
|
||||
)
|
||||
|
||||
try:
|
||||
bot: Bot = request.app['bot']
|
||||
i18n_instance: JsonI18n = request.app['i18n']
|
||||
settings: Settings = request.app['settings']
|
||||
yk_service: YooKassaService = request.app['yookassa_service']
|
||||
panel_service: PanelApiService = request.app['panel_service']
|
||||
subscription_service: SubscriptionService = request.app[
|
||||
'subscription_service']
|
||||
referral_service: ReferralService = request.app['referral_service']
|
||||
except KeyError as e:
|
||||
async_session_factory: sessionmaker = request.app[
|
||||
'async_session_factory']
|
||||
except KeyError as e_app_ctx:
|
||||
logging.error(
|
||||
f"KeyError accessing app context in yookassa_webhook_route: {e}.",
|
||||
f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
|
||||
exc_info=True)
|
||||
return web.Response(status=500,
|
||||
text="Internal Server Error: Missing app context")
|
||||
return web.Response(
|
||||
status=500,
|
||||
text="Internal Server Error: Missing app context component")
|
||||
|
||||
try:
|
||||
event_json = await request.json()
|
||||
|
||||
notification_object = WebhookNotification(event_json)
|
||||
payment_data_from_notification = notification_object.object
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
|
||||
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
|
||||
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
|
||||
)
|
||||
|
||||
if not payment_data_from_notification or not hasattr(
|
||||
payment_data_from_notification,
|
||||
'metadata') or payment_data_from_notification.metadata is None:
|
||||
logging.error(
|
||||
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata."
|
||||
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_no_metadata")
|
||||
payment_dict_for_processing = {}
|
||||
if hasattr(payment_data_from_notification, 'model_dump'):
|
||||
payment_dict_for_processing = payment_data_from_notification.model_dump(
|
||||
exclude_none=True)
|
||||
if 'amount' in payment_dict_for_processing and isinstance(
|
||||
payment_dict_for_processing['amount'], Amount):
|
||||
amount_obj = payment_dict_for_processing['amount']
|
||||
payment_dict_for_processing['amount'] = {
|
||||
"value": str(amount_obj.value),
|
||||
"currency": str(amount_obj.currency)
|
||||
}
|
||||
elif 'amount' in payment_dict_for_processing and not isinstance(
|
||||
payment_dict_for_processing['amount'], dict):
|
||||
amount_obj_original = payment_data_from_notification.amount
|
||||
payment_dict_for_processing['amount'] = {
|
||||
"value": str(amount_obj_original.value),
|
||||
"currency": str(amount_obj_original.currency)
|
||||
} if hasattr(amount_obj_original, 'value') and hasattr(
|
||||
amount_obj_original, 'currency') else {
|
||||
"value": "0.0",
|
||||
"currency": "RUB"
|
||||
}
|
||||
elif hasattr(payment_data_from_notification, 'amount') and hasattr(
|
||||
payment_data_from_notification.amount, 'value') and hasattr(
|
||||
payment_data_from_notification.amount, 'currency'):
|
||||
amount_obj = payment_data_from_notification.amount
|
||||
payment_dict_for_processing = {
|
||||
"id":
|
||||
str(payment_data_from_notification.id),
|
||||
"status":
|
||||
str(payment_data_from_notification.status),
|
||||
"paid":
|
||||
bool(payment_data_from_notification.paid),
|
||||
"amount": {
|
||||
"value": str(amount_obj.value),
|
||||
"currency": str(amount_obj.currency)
|
||||
},
|
||||
"metadata":
|
||||
dict(payment_data_from_notification.metadata)
|
||||
if payment_data_from_notification.metadata else {},
|
||||
"description":
|
||||
str(payment_data_from_notification.description)
|
||||
if payment_data_from_notification.description else None
|
||||
}
|
||||
else:
|
||||
logging.error(
|
||||
f"Could not serialize payment_data for payment {payment_data_from_notification.id}"
|
||||
)
|
||||
return web.Response(status=200, text="ok_error_serialization")
|
||||
|
||||
payment_dict_for_processing = {
|
||||
"id":
|
||||
str(payment_data_from_notification.id),
|
||||
"status":
|
||||
str(payment_data_from_notification.status),
|
||||
"paid":
|
||||
bool(payment_data_from_notification.paid),
|
||||
"amount": {
|
||||
"value": str(payment_data_from_notification.amount.value),
|
||||
"currency": str(payment_data_from_notification.amount.currency)
|
||||
} if payment_data_from_notification.amount else {},
|
||||
"metadata":
|
||||
dict(payment_data_from_notification.metadata),
|
||||
"description":
|
||||
str(payment_data_from_notification.description)
|
||||
if payment_data_from_notification.description else None,
|
||||
}
|
||||
|
||||
async with payment_processing_lock:
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
await process_successful_payment(
|
||||
bot, payment_dict_for_processing, i18n_instance,
|
||||
settings, panel_service, yk_service,
|
||||
subscription_service, referral_service)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(bot,
|
||||
payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||
if payment_dict_for_processing.get(
|
||||
"paid") and payment_dict_for_processing.get(
|
||||
"status") == "succeeded":
|
||||
await process_successful_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings, panel_service,
|
||||
subscription_service, referral_service)
|
||||
await session.commit()
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||
)
|
||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||
await process_cancelled_payment(
|
||||
session, bot, payment_dict_for_processing,
|
||||
i18n_instance, settings)
|
||||
await session.commit()
|
||||
except Exception as e_webhook_db_processing:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Error processing YooKassa webhook event '{notification_object.event}' "
|
||||
f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
|
||||
exc_info=True)
|
||||
return web.Response(
|
||||
status=200, text="ok_internal_processing_error_logged")
|
||||
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logging.error("YooKassa Webhook: Invalid JSON.")
|
||||
return web.Response(status=200, text="ok_invalid_json")
|
||||
except KeyError as e:
|
||||
logging.error("YooKassa Webhook: Invalid JSON received.")
|
||||
return web.Response(status=400, text="bad_request_invalid_json")
|
||||
except Exception as e_general_webhook:
|
||||
logging.error(
|
||||
f"KeyError in yookassa_webhook_route after initial context access: {e}.",
|
||||
f"YooKassa Webhook general processing error: {e_general_webhook}",
|
||||
exc_info=True)
|
||||
return web.Response(
|
||||
status=500,
|
||||
text="Internal Server Error: Context error post-access")
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa Webhook processing error: {e}", exc_info=True)
|
||||
return web.Response(status=200, text="ok_internal_error")
|
||||
return web.Response(status=200,
|
||||
text="ok_general_internal_error_logged")
|
||||
|
||||
@@ -3,6 +3,8 @@ import re
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram.utils.markdown import hcode
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.states.user_states import UserPromoStates
|
||||
@@ -10,26 +12,26 @@ from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from aiogram.utils.markdown import hcode
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
router = Router(name="user_promo_router")
|
||||
|
||||
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
|
||||
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|;\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
|
||||
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
|
||||
r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
|
||||
re.IGNORECASE)
|
||||
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
|
||||
MAX_PROMO_CODE_INPUT_LENGTH = 100
|
||||
|
||||
|
||||
async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
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 callback.answer("Language error.", show_alert=True)
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
@@ -44,8 +46,10 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
await callback.message.edit_text(
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to edit message for promo prompt: {e}")
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(
|
||||
text=_(key="promo_code_prompt"),
|
||||
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
|
||||
@@ -53,73 +57,96 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
await callback.answer()
|
||||
await state.set_state(UserPromoStates.waiting_for_promo_code)
|
||||
logging.info(
|
||||
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. FSM state: {await state.get_state()}"
|
||||
)
|
||||
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
|
||||
f"FSM state: {await state.get_state()}")
|
||||
|
||||
|
||||
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
|
||||
async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
promo_code_service: PromoCodeService,
|
||||
bot: Bot):
|
||||
bot: Bot, session: AsyncSession):
|
||||
logging.info(
|
||||
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
|
||||
)
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
if not i18n or not promo_code_service:
|
||||
logging.error("Deps missing in process_promo_code_input")
|
||||
await message.reply("Service error. Please try again.")
|
||||
logging.error(
|
||||
"Dependencies (i18n or PromoCodeService) missing in process_promo_code_input"
|
||||
)
|
||||
await message.reply("Service error. Please try again later.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
code_input = message.text.strip()
|
||||
code_input = message.text.strip() if message.text else ""
|
||||
user = message.from_user
|
||||
|
||||
is_suspicious = False
|
||||
if SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
|
||||
code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input) or len(
|
||||
code_input) > 100:
|
||||
if not code_input:
|
||||
is_suspicious = True
|
||||
logging.warning(f"Empty promo code input by user {user.id}.")
|
||||
elif len(
|
||||
code_input
|
||||
) > MAX_PROMO_CODE_INPUT_LENGTH or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
|
||||
code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input):
|
||||
is_suspicious = True
|
||||
logging.warning(
|
||||
f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'"
|
||||
f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'"
|
||||
)
|
||||
|
||||
response_to_user_text = ""
|
||||
if is_suspicious:
|
||||
admin_notify_key = "admin_suspicious_promo_attempt_notification_no_username" if not user.username else "admin_suspicious_promo_attempt_notification"
|
||||
admin_lang = settings.DEFAULT_LANGUAGE
|
||||
_admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
|
||||
admin_notification_text = _admin(admin_notify_key,
|
||||
user_id=user.id,
|
||||
user_username=user.username or "N/A",
|
||||
user_first_name=user.first_name
|
||||
or "N/A",
|
||||
promo_code_input=hcode(code_input))
|
||||
try:
|
||||
await bot.send_message(settings.ADMIN_ID,
|
||||
admin_notification_text,
|
||||
parse_mode="HTML")
|
||||
except Exception as e_admin_notify:
|
||||
logging.error(
|
||||
f"Failed to send suspicious promo notification to admin: {e_admin_notify}"
|
||||
)
|
||||
|
||||
if settings.ADMIN_IDS:
|
||||
admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username"
|
||||
|
||||
admin_lang = settings.DEFAULT_LANGUAGE
|
||||
_admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
|
||||
admin_notification_text = _admin(
|
||||
admin_notify_key,
|
||||
user_id=user.id,
|
||||
user_username=user.username or "N/A",
|
||||
user_first_name=user.first_name or "N/A",
|
||||
promo_code_input=hcode(code_input))
|
||||
for admin_id in settings.ADMIN_IDS:
|
||||
try:
|
||||
await bot.send_message(admin_id,
|
||||
admin_notification_text,
|
||||
parse_mode="HTML")
|
||||
except Exception as e_admin_notify:
|
||||
logging.error(
|
||||
f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}"
|
||||
)
|
||||
|
||||
response_to_user_text = _("promo_code_not_found",
|
||||
code=code_input.upper())
|
||||
code=hcode(code_input.upper()))
|
||||
else:
|
||||
|
||||
success, response_text_from_service = await promo_code_service.apply_promo_code(
|
||||
user.id, code_input, current_lang)
|
||||
session, user.id, code_input, current_lang)
|
||||
response_to_user_text = response_text_from_service
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}"
|
||||
)
|
||||
|
||||
await message.answer(response_to_user_text,
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
current_lang, i18n))
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await state.clear()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared."
|
||||
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
|
||||
)
|
||||
|
||||
|
||||
@@ -127,9 +154,9 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
UserPromoStates.waiting_for_promo_code)
|
||||
async def cancel_promo_input_via_button(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
|
||||
i18n_data: dict, subscription_service: SubscriptionService):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
i18n_data: dict, subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in cancel_promo_input_via_button")
|
||||
@@ -140,21 +167,17 @@ async def cancel_promo_input_via_button(
|
||||
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state."
|
||||
)
|
||||
await state.clear()
|
||||
logging.info(
|
||||
f"State after clear for user {callback.from_user.id}: {await state.get_state()}"
|
||||
)
|
||||
|
||||
if callback.message:
|
||||
show_trial_button_on_back = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
callback.from_user.id):
|
||||
show_trial_button_on_back = True
|
||||
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=show_trial_button_on_back,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
else:
|
||||
|
||||
await callback.answer("Promo code input cancelled.", show_alert=False)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await callback.answer(_("promo_input_cancelled_short"),
|
||||
show_alert=False)
|
||||
|
||||
@@ -1,67 +1,88 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.referral_service import ReferralService
|
||||
from db.database import get_db_connection_manager
|
||||
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard, get_back_to_main_menu_markup
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="user_referral_router")
|
||||
|
||||
|
||||
async def referral_command_handler(event: types.Message | types.CallbackQuery,
|
||||
async def referral_command_handler(event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
settings: Settings, i18n_data: dict,
|
||||
referral_service: ReferralService,
|
||||
bot: Bot):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
referral_service: ReferralService, bot: Bot,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
target_message = event.message if isinstance(
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message:
|
||||
if not target_message_obj:
|
||||
logging.error(
|
||||
"Target message is None in referral_command_handler from callback."
|
||||
"Target message is None in referral_command_handler (possibly from callback without message)."
|
||||
)
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer("Error displaying referral info.")
|
||||
await event.answer("Error displaying referral info.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if not i18n or not referral_service:
|
||||
logging.error("Deps missing in referral_command_handler")
|
||||
await target_message.answer("Service error." if isinstance(
|
||||
event, types.Message) else "Service error.",
|
||||
parse_mode=None)
|
||||
logging.error(
|
||||
"Dependencies (i18n or ReferralService) missing in referral_command_handler"
|
||||
)
|
||||
await target_message_obj.answer(
|
||||
"Service error. Please try again later.")
|
||||
if isinstance(event, types.CallbackQuery): await event.answer()
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username
|
||||
except Exception as e_bot_info:
|
||||
logging.error(
|
||||
f"Failed to get bot info for referral link: {e_bot_info}")
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery): await event.answer()
|
||||
return
|
||||
|
||||
if not bot_username:
|
||||
logging.error("Bot username is None, cannot generate referral link.")
|
||||
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||
if isinstance(event, types.CallbackQuery): await event.answer()
|
||||
return
|
||||
|
||||
inviter_user_id = event.from_user.id
|
||||
referral_link = referral_service.generate_referral_link(
|
||||
bot_username, inviter_user_id)
|
||||
bonus_info_parts = []
|
||||
|
||||
if hasattr(settings,
|
||||
'subscription_options') and settings.subscription_options:
|
||||
for months_period in sorted(settings.subscription_options.keys()):
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period)
|
||||
bonus_info_parts = []
|
||||
if settings.subscription_options:
|
||||
|
||||
for months_period_key, _price in sorted(
|
||||
settings.subscription_options.items()):
|
||||
|
||||
inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
|
||||
ref_bonus = settings.referral_bonus_referee.get(months_period_key)
|
||||
if inv_bonus is not None or ref_bonus is not None:
|
||||
bonus_info_parts.append(
|
||||
_("referral_bonus_per_period",
|
||||
months=months_period,
|
||||
months=months_period_key,
|
||||
inviter_bonus_days=inv_bonus
|
||||
if inv_bonus is not None else _("no_bonus_days"),
|
||||
if inv_bonus is not None else _("no_bonus_placeholder"),
|
||||
referee_bonus_days=ref_bonus
|
||||
if ref_bonus is not None else _("no_bonus_days")))
|
||||
if ref_bonus is not None else _("no_bonus_placeholder")))
|
||||
|
||||
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
|
||||
"referral_no_bonuses_configured")
|
||||
|
||||
text = _("referral_program_info_new",
|
||||
referral_link=referral_link,
|
||||
bonus_details=bonus_details_str)
|
||||
@@ -72,29 +93,16 @@ async def referral_command_handler(event: types.Message | types.CallbackQuery,
|
||||
await event.answer(text,
|
||||
reply_markup=reply_markup_val,
|
||||
disable_web_page_preview=True)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
elif isinstance(event, types.CallbackQuery) and event.message:
|
||||
try:
|
||||
await event.message.edit_text(text,
|
||||
reply_markup=reply_markup_val,
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to edit message for referral info: {e}")
|
||||
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Failed to edit message for referral info: {e_edit}. Sending new one."
|
||||
)
|
||||
await event.message.answer(text,
|
||||
reply_markup=reply_markup_val,
|
||||
disable_web_page_preview=True)
|
||||
await event.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "copy_referral_link_ack")
|
||||
async def copy_referral_link_ack_callback_handler(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
return await callback.answer("Language service error.",
|
||||
show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await callback.answer(text=_(key="referral_link_for_copying_reminder"),
|
||||
show_alert=False)
|
||||
|
||||
+224
-174
@@ -2,10 +2,12 @@ import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from db.dal import user_dal
|
||||
|
||||
from db.database import add_user_if_not_exists, update_user_language_code
|
||||
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
@@ -13,228 +15,272 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
async def send_main_menu(message_or_callback: types.Message
|
||||
| types.CallbackQuery,
|
||||
async def send_main_menu(target_event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
show_trial_button_flag: bool,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
is_edit: bool = False):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
answered_callback_internally = False
|
||||
|
||||
user_id = target_event.from_user.id
|
||||
user_full_name = target_event.from_user.full_name
|
||||
|
||||
if not i18n:
|
||||
logging.error("i18n_instance missing in send_main_menu")
|
||||
target_mc_for_error = message_or_callback if isinstance(
|
||||
message_or_callback,
|
||||
types.Message) else message_or_callback.message
|
||||
error_text_fallback = "Error: Language service unavailable."
|
||||
if target_mc_for_error:
|
||||
logging.error(
|
||||
f"i18n_instance missing in send_main_menu for user {user_id}")
|
||||
err_msg_fallback = "Error: Language service unavailable. Please try again later."
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
try:
|
||||
await target_mc_for_error.answer(error_text_fallback)
|
||||
except Exception as e_ans:
|
||||
logging.error(
|
||||
f"Failed to send error message in send_main_menu: {e_ans}")
|
||||
if isinstance(message_or_callback, types.CallbackQuery):
|
||||
await message_or_callback.answer()
|
||||
answered_callback_internally = True
|
||||
await target_event.answer(err_msg_fallback, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(target_event, types.Message) and hasattr(
|
||||
target_event, 'chat') and target_event.chat:
|
||||
try:
|
||||
await target_event.chat.send_message(err_msg_fallback)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
user_full_name = message_or_callback.from_user.full_name
|
||||
text = _(key="main_menu_greeting", user_name=user_full_name)
|
||||
reply_markup: Optional[
|
||||
InlineKeyboardMarkup] = get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_flag)
|
||||
target_message: Optional[types.Message] = None
|
||||
if isinstance(message_or_callback, types.Message):
|
||||
target_message = message_or_callback
|
||||
elif isinstance(message_or_callback, types.CallbackQuery):
|
||||
target_message = message_or_callback.message
|
||||
|
||||
if not target_message:
|
||||
show_trial_button_in_menu = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if hasattr(
|
||||
subscription_service, 'has_had_any_subscription') and callable(
|
||||
getattr(subscription_service, 'has_had_any_subscription')):
|
||||
if not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
show_trial_button_in_menu = True
|
||||
else:
|
||||
logging.error(
|
||||
"Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
|
||||
)
|
||||
|
||||
text = _(key="main_menu_greeting", user_name=user_full_name)
|
||||
reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
|
||||
show_trial_button_in_menu)
|
||||
|
||||
target_message_obj: Optional[types.Message] = None
|
||||
if isinstance(target_event, types.Message):
|
||||
target_message_obj = target_event
|
||||
elif isinstance(target_event,
|
||||
types.CallbackQuery) and target_event.message:
|
||||
target_message_obj = target_event.message
|
||||
|
||||
if not target_message_obj:
|
||||
logging.error(
|
||||
f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}."
|
||||
f"send_main_menu: target_message_obj is None for event from user {user_id}."
|
||||
)
|
||||
if isinstance(
|
||||
message_or_callback,
|
||||
types.CallbackQuery) and not answered_callback_internally:
|
||||
await message_or_callback.answer("Error displaying menu.")
|
||||
answered_callback_internally = True
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await target_event.answer(_("error_displaying_menu"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if is_edit:
|
||||
await target_message.edit_text(text, reply_markup=reply_markup)
|
||||
await target_message_obj.edit_text(text, reply_markup=reply_markup)
|
||||
else:
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
await target_message_obj.answer(text, reply_markup=reply_markup)
|
||||
|
||||
if isinstance(
|
||||
message_or_callback,
|
||||
types.CallbackQuery) and not answered_callback_internally:
|
||||
await message_or_callback.answer()
|
||||
answered_callback_internally = True
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await target_event.answer()
|
||||
except Exception as e_send_edit:
|
||||
logging.warning(
|
||||
f"Failed to send/edit main menu (user: {message_or_callback.from_user.id}): {e_send_edit}."
|
||||
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
|
||||
)
|
||||
if is_edit:
|
||||
if is_edit and target_message_obj and hasattr(
|
||||
target_message_obj, 'chat') and target_message_obj.chat:
|
||||
try:
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
await target_message_obj.chat.send_message(
|
||||
text, reply_markup=reply_markup)
|
||||
except Exception as e_send_new:
|
||||
logging.error(
|
||||
f"Also failed to send new main menu message: {e_send_new}")
|
||||
if isinstance(
|
||||
message_or_callback,
|
||||
types.CallbackQuery) and not answered_callback_internally:
|
||||
await message_or_callback.answer()
|
||||
answered_callback_internally = True
|
||||
|
||||
if isinstance(message_or_callback,
|
||||
types.CallbackQuery) and not answered_callback_internally:
|
||||
logging.warning(
|
||||
f"Callback {message_or_callback.id} was not answered in send_main_menu main logic paths."
|
||||
)
|
||||
await message_or_callback.answer()
|
||||
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
|
||||
)
|
||||
if isinstance(target_event, types.CallbackQuery):
|
||||
await target_event.answer(
|
||||
_("error_occurred_try_again") if is_edit else None)
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start_command_handler(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
async def start_command_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
bot: Bot):
|
||||
|
||||
session: AsyncSession,
|
||||
command: Optional[CommandStart] = None):
|
||||
await state.clear()
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n_instance not found")
|
||||
await message.answer("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
user_id = message.from_user.id
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
user = message.from_user
|
||||
user_id = user.id
|
||||
|
||||
referred_by_user_id: Optional[int] = None
|
||||
args = message.text.split()
|
||||
if len(args) > 1 and args[0] == "/start":
|
||||
if command and command.args:
|
||||
arg_payload = command.args
|
||||
if arg_payload.startswith("ref_"):
|
||||
try:
|
||||
potential_referrer_id_str = arg_payload.split("_")[1]
|
||||
if potential_referrer_id_str.isdigit():
|
||||
potential_referrer_id = int(potential_referrer_id_str)
|
||||
if potential_referrer_id != user_id:
|
||||
referred_by_user_id = potential_referrer_id
|
||||
except (IndexError, ValueError) as e:
|
||||
logging.warning(
|
||||
f"Could not parse referral from /start args '{arg_payload}': {e}"
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc)
|
||||
}
|
||||
try:
|
||||
referral_param = args[1]
|
||||
if referral_param.startswith("ref_") and referral_param.split(
|
||||
"_")[1].isdigit():
|
||||
potential_referrer_id = int(referral_param.split("_")[1])
|
||||
if potential_referrer_id != user_id:
|
||||
referred_by_user_id = potential_referrer_id
|
||||
except (ValueError, IndexError) as e:
|
||||
logging.warning(f"Could not parse referral: '{args[1]}' - {e}")
|
||||
db_op_success, was_new_bot_user = await add_user_if_not_exists(
|
||||
user_id=user_id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
lang_code=current_lang,
|
||||
referred_by_id=referred_by_user_id)
|
||||
if not db_op_success:
|
||||
await message.answer(_("error_occurred_processing_request"))
|
||||
return
|
||||
if referred_by_user_id:
|
||||
logging.info(
|
||||
f"User {user_id} started with referral from {referred_by_user_id}."
|
||||
)
|
||||
await message.answer(
|
||||
_(key="welcome", user_name=message.from_user.full_name))
|
||||
show_trial_button_in_menu = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(user_id):
|
||||
show_trial_button_in_menu = True
|
||||
logging.info(f"User {user_id} is eligible for a trial button.")
|
||||
db_user = await user_dal.create_user(session, user_data_to_create)
|
||||
|
||||
else:
|
||||
logging.info(
|
||||
f"User {user_id} not eligible for trial button (already had a subscription)."
|
||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||
)
|
||||
except Exception as e_create:
|
||||
|
||||
logging.error(
|
||||
f"Failed to add new user {user_id} to session: {e_create}",
|
||||
exc_info=True)
|
||||
await message.answer(_("error_occurred_processing_request"))
|
||||
return
|
||||
else:
|
||||
logging.info(f"Trial period is disabled in settings. No trial button.")
|
||||
update_payload = {}
|
||||
if db_user.language_code != current_lang:
|
||||
update_payload["language_code"] = current_lang
|
||||
if referred_by_user_id and db_user.referred_by_id is None:
|
||||
update_payload["referred_by_id"] = referred_by_user_id
|
||||
if user.username != db_user.username:
|
||||
update_payload["username"] = user.username
|
||||
if user.first_name != db_user.first_name:
|
||||
update_payload["first_name"] = user.first_name
|
||||
if user.last_name != db_user.last_name:
|
||||
update_payload["last_name"] = user.last_name
|
||||
|
||||
if update_payload:
|
||||
try:
|
||||
await user_dal.update_user(session, user_id, update_payload)
|
||||
|
||||
logging.info(
|
||||
f"Updated existing user {user_id} in session: {update_payload}"
|
||||
)
|
||||
except Exception as e_update:
|
||||
|
||||
logging.error(
|
||||
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||
exc_info=True)
|
||||
|
||||
await message.answer(_(key="welcome", user_name=user.full_name))
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=show_trial_button_in_menu)
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=False)
|
||||
|
||||
|
||||
@router.message(Command("language"))
|
||||
@router.callback_query(F.data == "main_action:language")
|
||||
async def language_command_handler(event: types.Message | types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings):
|
||||
|
||||
async def language_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
target_message_for_reply: Optional[types.Message] = None
|
||||
is_callback = isinstance(event, types.CallbackQuery)
|
||||
answered_callback = False
|
||||
if is_callback:
|
||||
await event.answer()
|
||||
answered_callback = True
|
||||
target_message_for_reply = event.message
|
||||
else:
|
||||
target_message_for_reply = event
|
||||
if not i18n:
|
||||
logging.error("i18n instance is missing in language_command_handler.")
|
||||
error_message_text = "Language service error."
|
||||
if target_message_for_reply:
|
||||
await target_message_for_reply.answer(error_message_text)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
text_to_send = _(key="choose_language")
|
||||
reply_markup_to_send = get_language_selection_keyboard(i18n, current_lang)
|
||||
if not target_message_for_reply:
|
||||
logging.warning("language_command_handler: No target message.")
|
||||
reply_markup = get_language_selection_keyboard(i18n, current_lang)
|
||||
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
if is_callback:
|
||||
try:
|
||||
await target_message_for_reply.edit_text(
|
||||
text_to_send, reply_markup=reply_markup_to_send)
|
||||
except Exception as e:
|
||||
logging.info(
|
||||
f"Could not edit for lang selection: {e}. Sending new.")
|
||||
await target_message_for_reply.answer(
|
||||
text_to_send, reply_markup=reply_markup_to_send)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
if event.message:
|
||||
try:
|
||||
await event.message.edit_text(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
await event.answer()
|
||||
else:
|
||||
await target_message_for_reply.answer(
|
||||
text_to_send, reply_markup=reply_markup_to_send)
|
||||
await target_message_obj.answer(text_to_send,
|
||||
reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("set_lang_"))
|
||||
async def select_language_callback_handler(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings,
|
||||
subscription_service: SubscriptionService):
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
await callback.answer("Service error or message context lost.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
lang_code = callback.data.split("_")[2]
|
||||
except IndexError:
|
||||
await callback.answer("Error processing language selection.",
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
lang_code = callback.data.split("_")[2]
|
||||
user_id = callback.from_user.id
|
||||
await update_user_language_code(user_id, lang_code)
|
||||
i18n_data["current_language"] = lang_code
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
|
||||
try:
|
||||
updated = await user_dal.update_user_language(session, user_id,
|
||||
lang_code)
|
||||
if updated:
|
||||
|
||||
await callback.answer(_(key="language_set_alert"))
|
||||
i18n_data["current_language"] = lang_code
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
|
||||
await callback.answer(_(key="language_set_alert"))
|
||||
logging.info(
|
||||
f"User {user_id} language updated to {lang_code} in session.")
|
||||
else:
|
||||
await callback.answer("Could not set language.", show_alert=True)
|
||||
return
|
||||
except Exception as e_lang_update:
|
||||
|
||||
show_trial_button_after_lang_change = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
user_id):
|
||||
show_trial_button_after_lang_change = True
|
||||
|
||||
await send_main_menu(
|
||||
callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=show_trial_button_after_lang_change,
|
||||
is_edit=True)
|
||||
logging.error(
|
||||
f"Error updating lang for user {user_id}: {e_lang_update}",
|
||||
exc_info=True)
|
||||
await callback.answer("Error setting language.", show_alert=True)
|
||||
return
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("main_action:"))
|
||||
@@ -242,44 +288,48 @@ async def main_action_callback_handler(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
|
||||
i18n_data: dict, bot: Bot, subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService, panel_service: PanelApiService,
|
||||
promo_code_service: PromoCodeService):
|
||||
|
||||
promo_code_service: PromoCodeService, session: AsyncSession):
|
||||
action = callback.data.split(":")[1]
|
||||
user_id = callback.from_user.id
|
||||
|
||||
from . import subscription as user_subscription_handlers
|
||||
from . import referral as user_referral_handlers
|
||||
from . import promo_user as user_promo_handlers
|
||||
from . import trial_handler as user_trial_handlers
|
||||
|
||||
if not callback.message:
|
||||
logging.error(f"Callback {callback.id} no message for {action}")
|
||||
await callback.answer("Error.")
|
||||
await callback.answer("Error: message context lost.", show_alert=True)
|
||||
return
|
||||
|
||||
if action == "subscribe":
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback, i18n_data, settings)
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "my_subscription":
|
||||
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service)
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "referral":
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback, settings, i18n_data, referral_service, bot)
|
||||
callback, settings, i18n_data, referral_service, bot, session)
|
||||
elif action == "apply_promo":
|
||||
await user_promo_handlers.prompt_promo_code_input(
|
||||
callback, state, i18n_data, settings)
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "request_trial":
|
||||
await user_trial_handlers.request_trial_confirmation_handler(
|
||||
callback, settings, i18n_data, subscription_service)
|
||||
callback, settings, i18n_data, subscription_service, session)
|
||||
elif action == "language":
|
||||
|
||||
await language_command_handler(callback, i18n_data, settings)
|
||||
elif action == "back_to_main":
|
||||
show_trial_button_on_back = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
callback.from_user.id):
|
||||
show_trial_button_on_back = True
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=show_trial_button_on_back,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
else:
|
||||
await callback.answer("Unknown action.", show_alert=True)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
i18n_data.get("current_language"), key, **kw) if i18n else key
|
||||
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
|
||||
|
||||
+259
-224
@@ -1,15 +1,12 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import add_payment_record, get_db_connection_manager, _setup_db_connection
|
||||
from db.dal import payment_dal
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
|
||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
||||
@@ -21,307 +18,345 @@ from bot.middlewares.i18n import JsonI18n
|
||||
router = Router(name="user_subscription_router")
|
||||
|
||||
|
||||
async def display_subscription_options(message_or_callback: types.Message
|
||||
| types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
async def display_subscription_options(event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing in display_subscription_options")
|
||||
|
||||
target_msg = message_or_callback.message if isinstance(
|
||||
message_or_callback, types.CallbackQuery) else message_or_callback
|
||||
if target_msg: await target_msg.answer("Language service error.")
|
||||
if isinstance(message_or_callback, types.CallbackQuery):
|
||||
await message_or_callback.answer()
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n:
|
||||
err_msg = "Language service error."
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(err_msg, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(err_msg)
|
||||
return
|
||||
|
||||
get_translation = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs)
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
text_content = get_text("select_subscription_period"
|
||||
) if settings.subscription_options else get_text(
|
||||
"no_subscription_options_available")
|
||||
|
||||
text = get_translation(
|
||||
"select_subscription_period"
|
||||
) if settings.subscription_options else get_translation(
|
||||
"no_subscription_options_available")
|
||||
reply_markup = get_subscription_options_keyboard(
|
||||
settings.subscription_options, currency_symbol_val, current_lang,
|
||||
i18n) if settings.subscription_options else None
|
||||
settings.subscription_options, currency_symbol_val, current_lang, i18n
|
||||
) if settings.subscription_options else get_back_to_main_menu_markup(
|
||||
current_lang, i18n)
|
||||
|
||||
target_message = message_or_callback.message if isinstance(
|
||||
message_or_callback, types.CallbackQuery) else message_or_callback
|
||||
answered_callback = False
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
if not target_message_obj:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(message_or_callback, types.CallbackQuery):
|
||||
|
||||
await message_or_callback.answer()
|
||||
answered_callback = True
|
||||
|
||||
if target_message:
|
||||
if isinstance(message_or_callback, types.CallbackQuery):
|
||||
try:
|
||||
await target_message.edit_text(text, reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
else:
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
elif isinstance(message_or_callback, types.Message):
|
||||
await message_or_callback.answer(text, reply_markup=reply_markup)
|
||||
|
||||
if isinstance(message_or_callback,
|
||||
types.CallbackQuery) and not answered_callback:
|
||||
await message_or_callback.answer()
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await target_message_obj.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
await event.answer()
|
||||
else:
|
||||
await target_message_obj.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
|
||||
i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error(
|
||||
"i18n missing in select_subscription_period_callback_handler")
|
||||
await callback.answer("Service error. Please try again.",
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
get_translation = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
months = int(callback.data.split(":")[-1])
|
||||
except ValueError:
|
||||
logging.error(f"Invalid sub period: {callback.data}")
|
||||
await callback.answer(get_translation("error_try_again"),
|
||||
show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid subscription period in callback_data: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
price = settings.subscription_options.get(months)
|
||||
if price is None:
|
||||
logging.error(f"Price not found for {months} months subscription.")
|
||||
await callback.answer(get_translation("error_try_again"),
|
||||
show_alert=True)
|
||||
price_rub = settings.subscription_options.get(months)
|
||||
if price_rub is None:
|
||||
logging.error(
|
||||
f"Price not found for {months} months subscription period in settings.subscription_options."
|
||||
)
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
confirmation_text = get_translation("confirm_subscription_prompt",
|
||||
months=months,
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
reply_markup = get_confirm_subscription_keyboard(months, price,
|
||||
confirmation_text_content = get_text("confirm_subscription_prompt",
|
||||
months=months,
|
||||
price=f"{price_rub:.2f}",
|
||||
currency_symbol=currency_symbol_val)
|
||||
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
|
||||
currency_symbol_val,
|
||||
current_lang, i18n)
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.edit_text(confirmation_text,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e:
|
||||
logging.warning(f"Edit failed: {e}")
|
||||
await callback.message.answer(confirmation_text,
|
||||
reply_markup=reply_markup)
|
||||
try:
|
||||
await callback.message.edit_text(confirmation_text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(confirmation_text_content,
|
||||
reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_sub:"))
|
||||
async def confirm_subscription_callback_handler(
|
||||
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
|
||||
i18n_data: dict, yookassa_service: YooKassaService):
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
get_translation = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs)
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service missing or not configured")
|
||||
await callback.message.edit_text(
|
||||
get_translation("payment_service_unavailable")
|
||||
) if callback.message else None
|
||||
await callback.answer(get_translation("payment_service_unavailable"),
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
|
||||
await callback.answer(get_text("error_occurred_try_again"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
if not yookassa_service or not yookassa_service.configured:
|
||||
logging.error("YooKassa service is not configured or unavailable.")
|
||||
target_msg_edit = callback.message
|
||||
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
|
||||
)
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"),
|
||||
show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
price = float(price_str)
|
||||
except ValueError:
|
||||
logging.error(f"Invalid confirm data: {callback.data}")
|
||||
await callback.answer(get_translation("error_try_again"),
|
||||
show_alert=True)
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid confirmation data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
description = get_translation("payment_description_subscription",
|
||||
months=months)
|
||||
currency = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
payment_metadata = {
|
||||
|
||||
payment_description = get_text("payment_description_subscription",
|
||||
months=months)
|
||||
currency_code_for_yk = "RUB"
|
||||
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": price_rub,
|
||||
"currency": currency_code_for_yk,
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": months,
|
||||
}
|
||||
db_payment_record = None
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
session, payment_record_data)
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
||||
)
|
||||
except Exception as e_db_payment:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
if not db_payment_record:
|
||||
await callback.message.edit_text(
|
||||
get_text("error_creating_payment_record"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
yookassa_metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"description": description
|
||||
"payment_db_id": str(db_payment_record.payment_id),
|
||||
}
|
||||
payment_db_id = await add_payment_record(user_id, None, None, price,
|
||||
currency, "pending_creation",
|
||||
description, months, None)
|
||||
if not payment_db_id:
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
get_translation("error_creating_payment_record"))
|
||||
await callback.answer(show_alert=True)
|
||||
return
|
||||
payment_metadata["payment_db_id"] = str(payment_db_id)
|
||||
payment_response = await yookassa_service.create_payment(
|
||||
price, currency, description, payment_metadata)
|
||||
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
if callback.message:
|
||||
if payment_response and payment_response.get("confirmation_url"):
|
||||
async with get_db_connection_manager() as db:
|
||||
await _setup_db_connection(db)
|
||||
await db.execute(
|
||||
"UPDATE payments SET yookassa_payment_id = ?, idempotence_key = ?, status = ? WHERE payment_id = ?",
|
||||
(payment_response["id"],
|
||||
payment_response.get("idempotence_key"),
|
||||
payment_response["status"], payment_db_id))
|
||||
await db.commit()
|
||||
payment_response_yk = await yookassa_service.create_payment(
|
||||
amount=price_rub,
|
||||
currency=currency_code_for_yk,
|
||||
description=payment_description,
|
||||
metadata=yookassa_metadata,
|
||||
receipt_email=receipt_email_for_yk)
|
||||
|
||||
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
yk_payment_id=payment_response_yk.get("id"))
|
||||
await session.commit()
|
||||
except Exception as e_db_update_ykid:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
||||
exc_info=True)
|
||||
await callback.message.edit_text(
|
||||
get_translation(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False)
|
||||
else:
|
||||
async with get_db_connection_manager() as db:
|
||||
await _setup_db_connection(db)
|
||||
await db.execute(
|
||||
"UPDATE payments SET status = ? WHERE payment_id = ?",
|
||||
("failed_creation", payment_db_id))
|
||||
await db.commit()
|
||||
await callback.message.edit_text(
|
||||
get_translation("error_payment_gateway"))
|
||||
get_text("error_payment_gateway_link_failed"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
get_text(key="payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(
|
||||
payment_response_yk["confirmation_url"], current_lang, i18n),
|
||||
disable_web_page_preview=False)
|
||||
else:
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session, db_payment_record.payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
except Exception as e_db_fail_create:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
||||
exc_info=True)
|
||||
|
||||
logging.error(
|
||||
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
||||
)
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings):
|
||||
await display_subscription_options(callback, i18n_data, settings)
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
await display_subscription_options(callback, i18n_data, settings, session)
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
message_event: types.Message | types.CallbackQuery, i18n_data: dict,
|
||||
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
|
||||
settings: Settings, panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService):
|
||||
target_message = message_event.message if isinstance(
|
||||
message_event, types.CallbackQuery) else message_event
|
||||
user = message_event.from_user
|
||||
if isinstance(message_event, types.CallbackQuery):
|
||||
await message_event.answer()
|
||||
subscription_service: SubscriptionService, session: AsyncSession,
|
||||
bot: Bot):
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
user = event.from_user
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
logging.error("i18n missing")
|
||||
await target_message.answer("Lang error")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not target_message_obj:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
get_translation = lambda key, **kwargs: i18n.gettext(
|
||||
current_lang, key, **kwargs)
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
logging.error("Services missing")
|
||||
await target_message.answer(
|
||||
get_translation("error_service_unavailable"))
|
||||
logging.error(
|
||||
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
|
||||
)
|
||||
await target_message_obj.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active_sub = await subscription_service.get_active_subscription(user.id)
|
||||
sub_info_text = ""
|
||||
if active_sub:
|
||||
end_date_obj = active_sub.get('end_date')
|
||||
if isinstance(end_date_obj, str):
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_obj.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Could not parse date string '{end_date_obj}'.")
|
||||
end_date_obj = datetime.now(timezone.utc)
|
||||
active_sub_details = await subscription_service.get_active_subscription_details(
|
||||
session, user.id)
|
||||
|
||||
if not isinstance(end_date_obj, datetime):
|
||||
end_date_obj = datetime.now(timezone.utc)
|
||||
if end_date_obj.tzinfo is None:
|
||||
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
|
||||
sub_info_text_content = ""
|
||||
if active_sub_details:
|
||||
end_date_obj = active_sub_details.get('end_date')
|
||||
days_left = 0
|
||||
if end_date_obj:
|
||||
if end_date_obj.tzinfo is None:
|
||||
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
|
||||
days_left = (end_date_obj.date() - datetime.now().date()).days
|
||||
|
||||
today_date_utc = datetime.now(timezone.utc).date()
|
||||
end_date_only = end_date_obj.date()
|
||||
days_left = (end_date_only - today_date_utc).days
|
||||
actual_config_link = active_sub_details.get('config_link') or get_text(
|
||||
"config_link_not_available")
|
||||
|
||||
actual_config_link = get_translation("config_link_not_available")
|
||||
panel_user_uuid = active_sub.get('panel_user_uuid')
|
||||
if panel_user_uuid:
|
||||
panel_user_data = await panel_service.get_user_by_uuid(
|
||||
panel_user_uuid)
|
||||
if panel_user_data:
|
||||
if panel_user_data.get('subscriptionUrl'):
|
||||
actual_config_link = panel_user_data['subscriptionUrl']
|
||||
elif panel_user_data.get('shortUuid'):
|
||||
link = await panel_service.get_subscription_link(
|
||||
panel_user_data['shortUuid'])
|
||||
if link: actual_config_link = link
|
||||
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
|
||||
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
|
||||
|
||||
traffic_limit_gb = get_translation("traffic_unlimited")
|
||||
traffic_used_gb = get_translation("traffic_na")
|
||||
if active_sub.get('traffic_limit_bytes'
|
||||
) and active_sub['traffic_limit_bytes'] > 0:
|
||||
traffic_limit_gb = f"{active_sub['traffic_limit_bytes'] / (1024**3):.2f} GB"
|
||||
if active_sub.get('traffic_used_bytes') is not None:
|
||||
traffic_used_gb = f"{active_sub['traffic_used_bytes'] / (1024**3):.2f} GB"
|
||||
traffic_limit_gb_str = get_text("traffic_unlimited")
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
|
||||
|
||||
sub_info_text = get_translation(
|
||||
traffic_used_gb_str = get_text("traffic_na")
|
||||
if traffic_used_bytes is not None:
|
||||
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
|
||||
|
||||
sub_info_text_content = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date_obj.strftime("%Y-%m-%d"),
|
||||
end_date=end_date_obj.strftime("%Y-%m-%d")
|
||||
if end_date_obj else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active_sub.get(
|
||||
'status_from_panel',
|
||||
get_translation('status_active')).capitalize(),
|
||||
status=active_sub_details.get(
|
||||
'status_from_panel', get_text('status_active')).capitalize(),
|
||||
config_link=actual_config_link,
|
||||
traffic_limit=traffic_limit_gb,
|
||||
traffic_used=traffic_used_gb)
|
||||
traffic_limit=traffic_limit_gb_str,
|
||||
traffic_used=traffic_used_gb_str)
|
||||
else:
|
||||
sub_info_text = get_translation("subscription_not_active")
|
||||
sub_info_text_content = get_text("subscription_not_active")
|
||||
logging.info(
|
||||
f"User {user.id} no active sub details for 'my_subscription'.")
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
if isinstance(message_event,
|
||||
types.CallbackQuery) and message_event.message:
|
||||
|
||||
if isinstance(event, types.CallbackQuery) and event.message:
|
||||
try:
|
||||
await message_event.message.edit_text(
|
||||
sub_info_text,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e:
|
||||
logging.warning(f"Edit 'my_sub' failed: {e}")
|
||||
await target_message.answer(sub_info_text,
|
||||
await event.message.edit_text(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
|
||||
)
|
||||
await bot.send_message(chat_id=target_message_obj.chat.id,
|
||||
text=sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
else:
|
||||
await target_message_obj.answer(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
else:
|
||||
await target_message.answer(sub_info_text,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
async def connect_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService):
|
||||
"""Handles the /connect command, showing subscription info."""
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession, bot: Bot):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings,
|
||||
panel_service, subscription_service)
|
||||
panel_service, subscription_service,
|
||||
session, bot)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
@@ -16,37 +16,44 @@ router = Router(name="user_trial_router")
|
||||
|
||||
async def request_trial_confirmation_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService):
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
user_id = callback.from_user.id
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error.", show_alert=True)
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
show_trial_btn_if_fail = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
user_id):
|
||||
show_trial_btn_if_fail = True
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
show_trial_btn_in_menu_if_fail = True
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
_("trial_feature_disabled"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings,
|
||||
show_trial_btn_if_fail))
|
||||
settings, False))
|
||||
await callback.answer()
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(user_id):
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings, False))
|
||||
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")
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_("trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
@@ -59,55 +66,58 @@ async def request_trial_confirmation_handler(
|
||||
async def confirm_activate_trial_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService, bot: Bot):
|
||||
panel_service: PanelApiService, session: AsyncSession):
|
||||
user_id = callback.from_user.id
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error.", show_alert=True)
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
show_trial_button_after_action = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
user_id):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=False,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(user_id):
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"),
|
||||
show_alert=True)
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=False,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
return
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(
|
||||
user_id)
|
||||
session, user_id)
|
||||
|
||||
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 = activation_result.get("end_date")
|
||||
config_link_for_trial = _("config_link_not_available")
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available")
|
||||
|
||||
if activation_result.get("subscription_url"):
|
||||
config_link_for_trial = activation_result["subscription_url"]
|
||||
elif activation_result.get("panel_short_uuid"):
|
||||
link = await panel_service.get_subscription_link(
|
||||
if config_link_for_trial == _(
|
||||
"config_link_not_available") and activation_result.get(
|
||||
"panel_short_uuid"):
|
||||
generated_link = await panel_service.get_subscription_link(
|
||||
activation_result["panel_short_uuid"])
|
||||
if link: config_link_for_trial = link
|
||||
if generated_link: config_link_for_trial = generated_link
|
||||
|
||||
traffic_gb_val = activation_result.get("traffic_gb",
|
||||
settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
@@ -117,30 +127,34 @@ async def confirm_activate_trial_handler(
|
||||
final_message_text_in_chat = _(
|
||||
"trial_activated_details_message",
|
||||
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||
end_date=end_date.strftime('%Y-%m-%d') if isinstance(
|
||||
end_date, datetime) else "N/A",
|
||||
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)
|
||||
show_trial_button_after_action = False
|
||||
else:
|
||||
message_key = activation_result.get(
|
||||
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)
|
||||
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
|
||||
|
||||
if callback.message:
|
||||
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}")
|
||||
await callback.message.answer(
|
||||
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(
|
||||
@@ -152,13 +166,11 @@ async def confirm_activate_trial_handler(
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
async def cancel_trial_activation(callback: types.CallbackQuery,
|
||||
settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService):
|
||||
show_trial_button_on_back = False
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
callback.from_user.id):
|
||||
show_trial_button_on_back = True
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
show_trial_button_flag=show_trial_button_on_back,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
|
||||
@@ -4,6 +4,8 @@ from typing import Optional, List, Any
|
||||
import math
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.models import User
|
||||
|
||||
|
||||
def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
@@ -79,41 +81,62 @@ def get_logs_pagination_keyboard(
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_banned_users_keyboard(users: List[Any], current_page: int,
|
||||
total_users_banned: int, i18n_instance,
|
||||
def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
total_banned: int, i18n_instance: JsonI18n,
|
||||
lang: str,
|
||||
settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
page_size = settings.LOGS_PAGE_SIZE
|
||||
total_pages = math.ceil(total_users_banned /
|
||||
page_size) if page_size > 0 else 1
|
||||
if total_users_banned == 0: total_pages = 1
|
||||
for user_row in users:
|
||||
user_display = user_row['first_name'] or f"ID: {user_row['user_id']}"
|
||||
if user_row['username']: user_display += f" (@{user_row['username']})"
|
||||
else:
|
||||
user_display = f"ID: {user_row['user_id']}" if not user_row[
|
||||
'first_name'] else user_display
|
||||
builder.button(text=user_display,
|
||||
callback_data=
|
||||
f"admin_user_card:{user_row['user_id']}:{current_page}")
|
||||
pagination_row = []
|
||||
if current_page > 0:
|
||||
pagination_row.append(
|
||||
|
||||
if not banned_users and total_banned == 0:
|
||||
pass
|
||||
|
||||
for user_row in banned_users:
|
||||
|
||||
user_display_parts = []
|
||||
if user_row.first_name:
|
||||
user_display_parts.append(user_row.first_name)
|
||||
if user_row.username:
|
||||
user_display_parts.append(f"(@{user_row.username})")
|
||||
if not user_display_parts:
|
||||
user_display_parts.append(f"ID: {user_row.user_id}")
|
||||
|
||||
user_display = " ".join(user_display_parts).strip()
|
||||
|
||||
button_text = _("admin_banned_user_button_text",
|
||||
user_display=user_display,
|
||||
user_id=user_row.user_id)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ " + _("prev_page_button", default="Prev"),
|
||||
callback_data=f"admin_action:view_banned:{current_page - 1}"))
|
||||
if (current_page + 1) * page_size < total_users_banned:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button", default="Next") + " ➡️",
|
||||
callback_data=f"admin_action:view_banned:{current_page + 1}"))
|
||||
if pagination_row: builder.row(*pagination_row)
|
||||
text=button_text,
|
||||
callback_data=
|
||||
f"admin_user_card:{user_row.user_id}:{current_page}"))
|
||||
|
||||
if total_banned > page_size:
|
||||
total_pages = math.ceil(total_banned / page_size)
|
||||
pagination_buttons = []
|
||||
if current_page > 0:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"admin_action:view_banned:{current_page - 1}"
|
||||
))
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="stub_page_display"))
|
||||
if current_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"admin_action:view_banned:{current_page + 1}"
|
||||
))
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="back_to_admin_panel_button"),
|
||||
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
+224
-124
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher, Router, F
|
||||
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
|
||||
from aiogram.types import Update
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
@@ -9,75 +11,120 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiohttp import web
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings, get_settings
|
||||
from .middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from .middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from .middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
from .handlers.user import user_router_aggregate
|
||||
from .handlers.user import payment as user_payment_webhook_module
|
||||
from db.database_setup import init_db_connection
|
||||
|
||||
from .handlers.admin import admin_router_aggregate
|
||||
from .filters.admin_filter import AdminFilter
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
|
||||
from db.database import get_db_connection_manager
|
||||
from bot.handlers.user import user_router_aggregate
|
||||
from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
|
||||
from .services.notification_service import schedule_subscription_notifications
|
||||
from .services.payment_service import YooKassaService
|
||||
from .services.panel_api_service import PanelApiService
|
||||
from .services.subscription_service import SubscriptionService
|
||||
from .services.referral_service import ReferralService
|
||||
from .services.promo_code_service import PromoCodeService
|
||||
from bot.services.notification_service import schedule_subscription_notifications
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
|
||||
|
||||
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):
|
||||
dp.include_router(user_router_aggregate)
|
||||
admin_filtered_router_wrapper = Router(
|
||||
name="admin_filtered_router_wrapper")
|
||||
|
||||
admin_main_router = Router(name="admin_main_filtered_router")
|
||||
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
|
||||
admin_filtered_router_wrapper.message.filter(admin_filter_instance)
|
||||
admin_filtered_router_wrapper.callback_query.filter(admin_filter_instance)
|
||||
admin_filtered_router_wrapper.include_router(admin_router_aggregate)
|
||||
dp.include_router(admin_filtered_router_wrapper)
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
async def on_startup_configured(dispatcher: Dispatcher):
|
||||
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
settings: Settings = dispatcher["settings"]
|
||||
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
|
||||
panel_service: PanelApiService = dispatcher["panel_service"]
|
||||
|
||||
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
try:
|
||||
|
||||
await schedule_subscription_notifications(bot, settings, i18n_instance,
|
||||
scheduler)
|
||||
scheduler, panel_service,
|
||||
async_session_factory)
|
||||
scheduler.start()
|
||||
dispatcher["scheduler"] = scheduler
|
||||
logging.info("STARTUP: APScheduler started.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
|
||||
exc_info=True)
|
||||
|
||||
telegram_webhook_url_to_set = getattr(settings,
|
||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
||||
if telegram_webhook_url_to_set:
|
||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
||||
logging.error(
|
||||
f"CRITICAL: Bot token in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}')."
|
||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
||||
"This is a major security vulnerability. Webhook will NOT be set."
|
||||
)
|
||||
full_telegram_webhook_url = "ERROR_URL"
|
||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
||||
else:
|
||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url}"
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
)
|
||||
try:
|
||||
current_webhook_info_before = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current webhook info BEFORE: {current_webhook_info_before.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if full_telegram_webhook_url != "ERROR_URL":
|
||||
|
||||
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
|
||||
try:
|
||||
current_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
@@ -90,46 +137,64 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
logging.error(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
|
||||
)
|
||||
|
||||
new_webhook_info = await bot.get_webhook_info()
|
||||
logging.info(
|
||||
f"STARTUP: Webhook info AFTER: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
|
||||
)
|
||||
if not new_webhook_info.url:
|
||||
logging.error(
|
||||
"STARTUP: CRITICAL - Webhook URL EMPTY after set attempt."
|
||||
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
|
||||
)
|
||||
else:
|
||||
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting webhook due to URL config error."
|
||||
)
|
||||
except Exception as e_setwebhook:
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True)
|
||||
else:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True)
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook."
|
||||
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)."
|
||||
)
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
scheduler: AsyncIOScheduler = dispatcher.get("scheduler")
|
||||
|
||||
scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||
if scheduler and scheduler.running:
|
||||
try:
|
||||
scheduler.shutdown(wait=False)
|
||||
logging.info("SHUTDOWN: APScheduler shut down.")
|
||||
except Exception as e:
|
||||
logging.error(f"SHUTDOWN: Error APScheduler: {e}", exc_info=True)
|
||||
panel_service: PanelApiService = dispatcher.get("panel_service")
|
||||
if panel_service and panel_service._session and not panel_service._session.closed:
|
||||
logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
|
||||
exc_info=True)
|
||||
|
||||
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
|
||||
if panel_service and hasattr(panel_service, 'close_session'):
|
||||
await panel_service.close_session()
|
||||
bot: Bot = dispatcher.get("bot_instance")
|
||||
if bot and bot.session and not bot.session.closed:
|
||||
await bot.session.close()
|
||||
logging.info("Panel API service session closed on shutdown.")
|
||||
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
if bot and bot.session:
|
||||
try:
|
||||
await bot.session.close()
|
||||
logging.info("SHUTDOWN: Aiogram Bot session closed.")
|
||||
except Exception as e:
|
||||
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
|
||||
|
||||
from db.database_setup import async_engine as global_async_engine
|
||||
if global_async_engine:
|
||||
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
|
||||
await global_async_engine.dispose()
|
||||
logging.info("SHUTDOWN: SQLAlchemy engine disposed.")
|
||||
|
||||
logging.info("SHUTDOWN: Bot on_shutdown_configured completed.")
|
||||
|
||||
|
||||
@@ -138,49 +203,52 @@ async def run_bot(settings_param: Settings):
|
||||
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
bot = Bot(token=settings_param.BOT_TOKEN, default=default_props)
|
||||
|
||||
local_async_session_factory = init_db_connection(settings_param)
|
||||
if local_async_session_factory is None:
|
||||
logging.critical(
|
||||
"Failed to initialize database connection and session factory. Exiting."
|
||||
)
|
||||
return
|
||||
|
||||
dp = Dispatcher(storage=storage, settings=settings_param, bot_instance=bot)
|
||||
|
||||
actual_bot_username = "your_bot_username"
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
actual_bot_username = bot_info.username
|
||||
logging.info(f"Bot username: @{actual_bot_username}")
|
||||
logging.info(f"Bot username resolved: @{actual_bot_username}")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}"
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||
)
|
||||
|
||||
default_lang = settings_param.DEFAULT_LANGUAGE
|
||||
i18n_instance = get_i18n_instance(path="locales", default=default_lang)
|
||||
i18n_instance = get_i18n_instance(path="locales",
|
||||
default=settings_param.DEFAULT_LANGUAGE)
|
||||
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default=actual_bot_username,
|
||||
bot_username_for_default_return=actual_bot_username,
|
||||
settings_obj=settings_param)
|
||||
|
||||
panel_service = PanelApiService(settings_param)
|
||||
subscription_service = SubscriptionService(get_db_connection_manager,
|
||||
settings_param, panel_service)
|
||||
referral_service = ReferralService(get_db_connection_manager,
|
||||
settings_param, subscription_service,
|
||||
|
||||
subscription_service = SubscriptionService(settings_param, panel_service)
|
||||
referral_service = ReferralService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
promo_code_service = PromoCodeService(get_db_connection_manager,
|
||||
settings_param, subscription_service,
|
||||
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["i18n_data"] = {
|
||||
"i18n_instance": i18n_instance,
|
||||
"current_language": default_lang
|
||||
}
|
||||
dp["yookassa_service"] = yookassa_service
|
||||
dp["panel_service"] = panel_service
|
||||
dp["subscription_service"] = subscription_service
|
||||
dp["referral_service"] = referral_service
|
||||
dp["promo_code_service"] = promo_code_service
|
||||
dp["async_session_factory"] = local_async_session_factory
|
||||
|
||||
dp.update.outer_middleware(
|
||||
DBSessionMiddleware(local_async_session_factory))
|
||||
dp.update.outer_middleware(
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
|
||||
dp.update.outer_middleware(
|
||||
@@ -189,106 +257,138 @@ async def run_bot(settings_param: Settings):
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
||||
|
||||
dp.startup.register(on_startup_configured)
|
||||
dp.shutdown.register(on_shutdown_configured)
|
||||
dp.shutdown.register(lambda: on_shutdown_configured(dp))
|
||||
|
||||
await register_all_routers(dp, settings_param)
|
||||
|
||||
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
logging.info(f"--- Determining Run Mode ---")
|
||||
logging.info(f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}'")
|
||||
logging.info(f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}'")
|
||||
should_run_aiohttp = bool(
|
||||
yk_webhook_base
|
||||
and settings_param.yookassa_webhook_path) or bool(tg_webhook_base)
|
||||
telegram_uses_webhook = bool(tg_webhook_base)
|
||||
telegram_should_poll = not telegram_uses_webhook
|
||||
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp}")
|
||||
logging.info(f"Decision: Telegram uses webhook: {telegram_uses_webhook}")
|
||||
logging.info(f"Decision: Telegram should poll: {telegram_should_poll}")
|
||||
logging.info(f"--- End Run Mode Decision ---")
|
||||
|
||||
should_run_aiohttp_server = bool(tg_webhook_base) or (
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path))
|
||||
|
||||
telegram_uses_webhook_mode = bool(tg_webhook_base)
|
||||
run_telegram_polling = not telegram_uses_webhook_mode
|
||||
|
||||
logging.info(f"--- Bot Run Mode Decision ---")
|
||||
logging.info(
|
||||
f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
|
||||
)
|
||||
logging.info(
|
||||
f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'"
|
||||
)
|
||||
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
|
||||
logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
|
||||
logging.info(f"--- End Bot Run Mode Decision ---")
|
||||
|
||||
web_app_runner = None
|
||||
main_tasks_to_await = []
|
||||
if should_run_aiohttp:
|
||||
main_tasks = []
|
||||
|
||||
if should_run_aiohttp_server:
|
||||
app = web.Application()
|
||||
app['bot'] = bot
|
||||
app['dp'] = dp
|
||||
app['settings'] = settings_param
|
||||
app['i18n'] = i18n_instance
|
||||
app['async_session_factory'] = local_async_session_factory
|
||||
|
||||
app['yookassa_service'] = yookassa_service
|
||||
app['panel_service'] = panel_service
|
||||
app['subscription_service'] = subscription_service
|
||||
app['referral_service'] = referral_service
|
||||
app['promo_code_service'] = promo_code_service
|
||||
app['panel_service'] = panel_service
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
logging.info(
|
||||
"AIOHTTP app context populated and dispatcher lifecycle linked.")
|
||||
if telegram_uses_webhook:
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
||||
if not telegram_webhook_path.startswith('/'):
|
||||
telegram_webhook_path = '/' + telegram_webhook_path
|
||||
app.router.add_post(telegram_webhook_path,
|
||||
SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
logging.info(f"Telegram webhook route: {telegram_webhook_path}.")
|
||||
if yk_webhook_base and settings_param.yookassa_webhook_path:
|
||||
app.router.add_post(
|
||||
settings_param.yookassa_webhook_path,
|
||||
user_payment_webhook_module.yookassa_webhook_route)
|
||||
logging.info(
|
||||
f"YK webhook route: {settings_param.yookassa_webhook_path}")
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
|
||||
if yk_webhook_base and settings_param.yookassa_webhook_path:
|
||||
yk_path = settings_param.yookassa_webhook_path
|
||||
if not yk_path or not isinstance(yk_path, str):
|
||||
logging.error(
|
||||
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
|
||||
)
|
||||
elif not yk_path.startswith('/'):
|
||||
logging.error(
|
||||
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
||||
)
|
||||
else:
|
||||
app.router.add_post(
|
||||
yk_path,
|
||||
user_payment_webhook_module.yookassa_webhook_route)
|
||||
logging.info(
|
||||
f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT)
|
||||
|
||||
async def web_server_task_wrapper():
|
||||
async def web_server_task():
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}."
|
||||
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
|
||||
)
|
||||
await asyncio.Event().wait(
|
||||
) if not telegram_should_poll else await asyncio.sleep(31536000)
|
||||
) if not run_telegram_polling else await asyncio.sleep(31536000)
|
||||
|
||||
main_tasks_to_await.append(
|
||||
asyncio.create_task(web_server_task_wrapper(),
|
||||
name="AIOHTTPServerWrapperTask"))
|
||||
if telegram_should_poll:
|
||||
logging.info("TG polling task created.")
|
||||
main_tasks_to_await.append(
|
||||
main_tasks.append(
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
||||
|
||||
if run_telegram_polling:
|
||||
logging.info("Starting bot in Telegram Polling mode...")
|
||||
main_tasks.append(
|
||||
asyncio.create_task(dp.start_polling(
|
||||
bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask"))
|
||||
if not main_tasks_to_await:
|
||||
logging.error("Bot not configured for any mode.")
|
||||
|
||||
if not main_tasks:
|
||||
logging.error(
|
||||
"Bot is not configured for any run mode (neither Webhook nor Polling). Exiting."
|
||||
)
|
||||
await dp.emit_shutdown()
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}"
|
||||
f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}"
|
||||
)
|
||||
|
||||
try:
|
||||
if main_tasks_to_await: await asyncio.gather(*main_tasks_to_await)
|
||||
await asyncio.gather(*main_tasks)
|
||||
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
|
||||
logging.info(f"Main loop interrupted: {type(e).__name__}")
|
||||
logging.info(
|
||||
f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||
finally:
|
||||
logging.info("Initiating final shutdown sequence...")
|
||||
for task in main_tasks_to_await:
|
||||
if task and not task.done(): task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
logging.info(f"Task '{task.get_name()}' cancelled.")
|
||||
except Exception as e_cancel:
|
||||
logging.error(
|
||||
f"Error cancelling task '{task.get_name()}': {e_cancel}",
|
||||
exc_info=True)
|
||||
logging.info("Initiating final bot shutdown sequence...")
|
||||
for task in main_tasks:
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
logging.info(
|
||||
f"Task '{task.get_name()}' was cancelled successfully."
|
||||
)
|
||||
except Exception as e_task_cancel:
|
||||
logging.error(
|
||||
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
|
||||
exc_info=True)
|
||||
|
||||
if web_app_runner:
|
||||
await web_app_runner.cleanup()
|
||||
logging.info("AIOHTTP AppRunner cleaned up.")
|
||||
if not (telegram_should_poll and len(main_tasks_to_await) == 1
|
||||
and any(t.get_name() == "TelegramPollingTask"
|
||||
for t in main_tasks_to_await if t and not t.done())):
|
||||
logging.info(
|
||||
"Explicitly calling dp.emit_shutdown() in run_bot finally.")
|
||||
await dp.emit_shutdown()
|
||||
if bot.session and not bot.session.closed: await bot.session.close()
|
||||
|
||||
await dp.emit_shutdown()
|
||||
logging.info("Dispatcher shutdown sequence emitted.")
|
||||
|
||||
logging.info("Bot run_bot function finished.")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Callable, Dict, Any, Awaitable, Union, Optional
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update, Message, CallbackQuery, User
|
||||
from aiogram.types import Update, User, Message, CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.database import log_user_action
|
||||
from db.dal import message_log_dal, user_dal
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -19,72 +20,82 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
Awaitable[Any]], event: Update,
|
||||
data: Dict[str, Any]) -> Any:
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
session: AsyncSession = data["session"]
|
||||
event_user: Optional[User] = data.get("event_from_user")
|
||||
bot: Optional[Bot] = data.get("bot")
|
||||
|
||||
user_id: Optional[int] = None
|
||||
telegram_username: Optional[str] = None
|
||||
telegram_first_name: Optional[str] = None
|
||||
event_type: str = event.event_type
|
||||
content: Optional[str] = None
|
||||
is_admin_event_flag: bool = False
|
||||
target_user_id_for_log: Optional[int] = None
|
||||
|
||||
if event_user:
|
||||
user_id = event_user.id
|
||||
telegram_username = event_user.username
|
||||
telegram_first_name = event_user.first_name
|
||||
if user_id == self.settings.ADMIN_IDS:
|
||||
if user_id in self.settings.ADMIN_IDS:
|
||||
is_admin_event_flag = True
|
||||
|
||||
raw_update_snippet = None
|
||||
try:
|
||||
|
||||
raw_update_snippet = event.model_dump_json(exclude_none=True,
|
||||
indent=None)[:1000]
|
||||
except AttributeError:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
except Exception:
|
||||
raw_update_snippet = str(event)[:1000]
|
||||
|
||||
current_event_type = event.event_type
|
||||
|
||||
if event.message:
|
||||
msg = event.message
|
||||
msg: Message = event.message
|
||||
if msg.text:
|
||||
content = msg.text
|
||||
if msg.text.startswith('/'):
|
||||
event_type = "command"
|
||||
elif msg.caption:
|
||||
content = f"[{msg.content_type}] {msg.caption}"
|
||||
current_event_type = f"command:{msg.text.split()[0]}"
|
||||
|
||||
else:
|
||||
content = f"[{msg.content_type}]"
|
||||
|
||||
content = f"[{msg.content_type or 'unknown_content_type'}]"
|
||||
current_event_type = f"message:{msg.content_type or 'unknown'}"
|
||||
elif event.callback_query:
|
||||
cb = event.callback_query
|
||||
event_type = "callback_query"
|
||||
cb: CallbackQuery = event.callback_query
|
||||
content = cb.data
|
||||
action_part = cb.data.split(
|
||||
":")[0] if cb.data and ":" in cb.data else cb.data
|
||||
current_event_type = f"callback:{action_part}"
|
||||
|
||||
if user_id and event_type and content:
|
||||
if user_id or current_event_type not in ["update"]:
|
||||
|
||||
log_user_id_for_db = user_id
|
||||
if user_id:
|
||||
user_exists = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user_exists:
|
||||
logging.warning(
|
||||
f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL."
|
||||
)
|
||||
log_user_id_for_db = None
|
||||
|
||||
log_payload = {
|
||||
"user_id": log_user_id_for_db,
|
||||
"telegram_username": telegram_username,
|
||||
"telegram_first_name": telegram_first_name,
|
||||
"event_type": current_event_type,
|
||||
"content": content[:1000] if content else "N/A",
|
||||
"raw_update_preview": raw_update_snippet,
|
||||
"is_admin_event": is_admin_event_flag,
|
||||
"target_user_id": target_user_id_for_log,
|
||||
"timestamp": datetime.now(timezone.utc)
|
||||
}
|
||||
try:
|
||||
await log_user_action(user_id=user_id,
|
||||
telegram_username=telegram_username,
|
||||
telegram_first_name=telegram_first_name,
|
||||
event_type=event_type,
|
||||
content=content[:1000],
|
||||
raw_update_preview=raw_update_snippet,
|
||||
is_admin_event=is_admin_event_flag)
|
||||
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session, log_payload)
|
||||
except Exception as e_log:
|
||||
logging.error(
|
||||
f"ActionLoggerMiddleware: Failed to log event for user {user_id}: {e_log}",
|
||||
exc_info=True)
|
||||
elif user_id and event_type:
|
||||
try:
|
||||
await log_user_action(user_id=user_id,
|
||||
telegram_username=telegram_username,
|
||||
telegram_first_name=telegram_first_name,
|
||||
event_type=event_type,
|
||||
content="N/A",
|
||||
raw_update_preview=raw_update_snippet,
|
||||
is_admin_event=is_admin_event_flag)
|
||||
except Exception as e_log:
|
||||
logging.error(
|
||||
f"ActionLoggerMiddleware: Failed to log event (no content) for user {user_id}: {e_log}",
|
||||
f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}",
|
||||
exc_info=True)
|
||||
|
||||
return await handler(event, data)
|
||||
return result
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable, Union, Optional
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional, Union
|
||||
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.types import Message, CallbackQuery, User, InlineKeyboardMarkup, Update
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import Message, CallbackQuery, User, Update, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramForbiddenError, TelegramBadRequest, AiogramError
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import get_user
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_user_banned_keyboard
|
||||
from db.dal import user_dal
|
||||
|
||||
from .i18n import JsonI18n
|
||||
from ..keyboards.inline.user_keyboards import get_user_banned_keyboard
|
||||
|
||||
|
||||
class BanCheckMiddleware(BaseMiddleware):
|
||||
@@ -22,191 +23,88 @@ class BanCheckMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
|
||||
Awaitable[Any]], event: Update,
|
||||
data: Dict[str, Any]) -> Any:
|
||||
session: AsyncSession = data["session"]
|
||||
event_user: Optional[User] = data.get("event_from_user")
|
||||
if not event_user: return await handler(event, data)
|
||||
bot_instance: Bot = data["bot"]
|
||||
|
||||
if not event_user:
|
||||
return await handler(event, data)
|
||||
|
||||
if event_user.id in self.settings.ADMIN_IDS:
|
||||
return await handler(event, data)
|
||||
|
||||
try:
|
||||
db_user_data = await get_user(user_id=event_user.id)
|
||||
db_user_model = await user_dal.get_user_by_id(
|
||||
session, event_user.id)
|
||||
except Exception as e_db:
|
||||
logging.error(
|
||||
f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}",
|
||||
exc_info=True)
|
||||
return await handler(event, data)
|
||||
|
||||
if db_user_data and db_user_data['is_banned'] == 1:
|
||||
if db_user_model and db_user_model.is_banned:
|
||||
logging.info(
|
||||
f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification."
|
||||
f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access."
|
||||
)
|
||||
|
||||
ban_message_text = "You are blocked. Please contact support."
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
current_lang = self.settings.DEFAULT_LANGUAGE
|
||||
i18n_to_use: Optional[JsonI18n] = None
|
||||
actual_event_object: Optional[Union[Message, CallbackQuery]] = None
|
||||
i18n_data_from_event = data.get("i18n_data", {})
|
||||
current_lang = i18n_data_from_event.get(
|
||||
"current_language", self.settings.DEFAULT_LANGUAGE)
|
||||
i18n_to_use: Optional[JsonI18n] = i18n_data_from_event.get(
|
||||
"i18n_instance", self.i18n_main_instance)
|
||||
|
||||
if event.message:
|
||||
actual_event_object = event.message
|
||||
ban_message_text = "You are banned. Please contact support."
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
|
||||
if i18n_to_use:
|
||||
_ = lambda k, **kw: i18n_to_use.gettext(current_lang, k, **kw)
|
||||
ban_message_text = _("user_is_banned")
|
||||
keyboard = get_user_banned_keyboard(self.settings.SUPPORT_LINK,
|
||||
current_lang, i18n_to_use)
|
||||
elif self.settings.SUPPORT_LINK:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="Support", url=self.settings.SUPPORT_LINK)
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
actual_event_object: Optional[Union[Message, CallbackQuery]] = None
|
||||
if event.message: actual_event_object = event.message
|
||||
elif event.callback_query:
|
||||
actual_event_object = event.callback_query
|
||||
|
||||
if not actual_event_object:
|
||||
logging.warning(
|
||||
f"BanCheck: Could not determine specific event type (Message/CallbackQuery) for banned user {event_user.id} from Update object. Update type: {event.type}"
|
||||
)
|
||||
|
||||
try:
|
||||
bot_instance: Bot = data["bot"]
|
||||
await bot_instance.send_message(event_user.id,
|
||||
ban_message_text)
|
||||
except Exception as e_direct_send:
|
||||
logging.error(
|
||||
f"BanCheck: Failed to send direct ban message to {event_user.id}: {e_direct_send}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
logging.debug(
|
||||
"BanCheck: [A] Inside main try block for banned user notification."
|
||||
)
|
||||
i18n_data_from_event = data.get("i18n_data", {})
|
||||
current_lang = i18n_data_from_event.get(
|
||||
"current_language", self.settings.DEFAULT_LANGUAGE)
|
||||
i18n_to_use = i18n_data_from_event.get("i18n_instance")
|
||||
logging.debug(
|
||||
f"BanCheck: [B] i18n_instance from event_data: {type(i18n_to_use)}. Current lang: {current_lang}"
|
||||
)
|
||||
|
||||
if not i18n_to_use:
|
||||
i18n_to_use = self.i18n_main_instance
|
||||
logging.warning(
|
||||
f"BanCheck: [B_fallback] Using fallback i18n instance for banned user {event_user.id}. Type: {type(i18n_to_use)}"
|
||||
)
|
||||
|
||||
if i18n_to_use:
|
||||
_ = lambda k, **kw: i18n_to_use.gettext(
|
||||
current_lang, k, **kw)
|
||||
logging.debug(
|
||||
"BanCheck: [D] Attempting to get 'user_is_banned' text."
|
||||
)
|
||||
ban_message_text = _("user_is_banned")
|
||||
logging.debug(
|
||||
f"BanCheck: [E] Ban message text: '{ban_message_text}'"
|
||||
)
|
||||
if self.settings.SUPPORT_LINK:
|
||||
logging.debug(
|
||||
"BanCheck: [F] Support link found. Attempting to get user_banned_keyboard."
|
||||
)
|
||||
keyboard = get_user_banned_keyboard(
|
||||
self.settings.SUPPORT_LINK, current_lang,
|
||||
i18n_to_use)
|
||||
logging.debug(
|
||||
f"BanCheck: [G] Keyboard created: {keyboard is not None}"
|
||||
)
|
||||
else:
|
||||
logging.debug(
|
||||
"BanCheck: [F_alt] No support link configured.")
|
||||
else:
|
||||
logging.error(
|
||||
f"BanCheck: [CRITICAL] No i18n instance for user {event_user.id}. Using hardcoded text."
|
||||
)
|
||||
if self.settings.SUPPORT_LINK:
|
||||
kb_temp = InlineKeyboardBuilder()
|
||||
kb_temp.button(text="Support",
|
||||
url=self.settings.SUPPORT_LINK)
|
||||
keyboard = kb_temp.as_markup()
|
||||
logging.debug(
|
||||
"BanCheck: [G_alt] Fallback keyboard created due to no i18n."
|
||||
)
|
||||
|
||||
logging.debug(
|
||||
f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}"
|
||||
)
|
||||
|
||||
if isinstance(actual_event_object, Message):
|
||||
logging.debug(
|
||||
f"BanCheck: [I_Msg] Attempting actual_event_object.answer for Message to user {event_user.id}"
|
||||
)
|
||||
await actual_event_object.answer(ban_message_text,
|
||||
reply_markup=keyboard)
|
||||
logging.info(
|
||||
f"BanCheck: [J_Msg] Ban notification 'actual_event_object.answer' attempted for user {event_user.id} (Message)."
|
||||
)
|
||||
elif isinstance(actual_event_object, CallbackQuery):
|
||||
logging.debug(
|
||||
f"BanCheck: [I_CB] Attempting actual_event_object.answer (alert) for CallbackQuery to user {event_user.id}"
|
||||
)
|
||||
await actual_event_object.answer(ban_message_text,
|
||||
show_alert=True)
|
||||
logging.info(
|
||||
f"BanCheck: [J_CB] Ban alert 'actual_event_object.answer' attempted for user {event_user.id} (CallbackQuery)."
|
||||
)
|
||||
|
||||
target_message_obj = actual_event_object.message
|
||||
if target_message_obj:
|
||||
target_chat_id = target_message_obj.chat.id
|
||||
if actual_event_object.message:
|
||||
try:
|
||||
logging.debug(
|
||||
f"BanCheck: [K_CB_Edit] Attempting target_message_obj.edit_text for user {event_user.id}"
|
||||
)
|
||||
await target_message_obj.edit_text(
|
||||
await actual_event_object.message.edit_text(
|
||||
ban_message_text, reply_markup=keyboard)
|
||||
logging.info(
|
||||
f"BanCheck: [L_CB_Edit] Ban msg 'target_message_obj.edit_text' attempted for user {event_user.id} (Callback)."
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"BanCheck: [M_CB_EditFail] Failed to edit message for banned user {event_user.id}: {type(e_edit).__name__} - {e_edit}. Sending new message."
|
||||
)
|
||||
await actual_event_object.bot.send_message(
|
||||
target_chat_id,
|
||||
except (TelegramAPIError, AiogramError):
|
||||
await bot_instance.send_message(
|
||||
actual_event_object.from_user.id,
|
||||
ban_message_text,
|
||||
reply_markup=keyboard)
|
||||
logging.info(
|
||||
f"BanCheck: [N_CB_NewMsg] Ban msg 'actual_event_object.bot.send_message' (after edit fail) attempted for user {event_user.id} (Callback)."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"BanCheck: [K_CB_NoMsg] CallbackQuery from {event_user.id} has no .message attribute. Sending new message directly."
|
||||
)
|
||||
await actual_event_object.bot.send_message(
|
||||
await bot_instance.send_message(
|
||||
actual_event_object.from_user.id,
|
||||
ban_message_text,
|
||||
reply_markup=keyboard)
|
||||
logging.info(
|
||||
f"BanCheck: [L_CB_NoMsg_NewMsg] Ban msg 'actual_event_object.bot.send_message' (no .message) attempted for user {event_user.id} (Callback)."
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"BanCheck: [UNHANDLED_EVENT_TYPE_INTERNAL] actual_event_object type {type(actual_event_object)} was not Message or CallbackQuery."
|
||||
)
|
||||
|
||||
except TelegramForbiddenError as e_forbidden:
|
||||
await bot_instance.send_message(event_user.id,
|
||||
ban_message_text,
|
||||
reply_markup=keyboard)
|
||||
logging.info(f"Ban notification sent to user {event_user.id}.")
|
||||
except TelegramForbiddenError:
|
||||
logging.warning(
|
||||
f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}"
|
||||
)
|
||||
except TelegramBadRequest as e_bad_req:
|
||||
f"BanCheck: Bot is blocked by user {event_user.id}.")
|
||||
except Exception as e_send:
|
||||
logging.error(
|
||||
f"BanCheck: TelegramBadRequest sending ban msg to {event_user.id}: {e_bad_req}",
|
||||
exc_info=True)
|
||||
except TelegramAPIError as e_api:
|
||||
logging.error(
|
||||
f"BanCheck: TelegramAPIError sending ban msg to {event_user.id}: {e_api}",
|
||||
exc_info=True)
|
||||
except AiogramError as e_aio:
|
||||
logging.error(
|
||||
f"BanCheck: AiogramError sending ban msg to {event_user.id}: {e_aio}",
|
||||
exc_info=True)
|
||||
except Exception as e_general:
|
||||
logging.error(
|
||||
f"BanCheck: Generic failure preparing or sending ban notification to user {event_user.id}: {e_general}",
|
||||
f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}",
|
||||
exc_info=True)
|
||||
|
||||
logging.debug(
|
||||
f"BanCheck: [Z] End of ban processing for user {event_user.id}. Returning to stop further handlers."
|
||||
)
|
||||
return
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
+66
-69
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import aiosqlite
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, User, Update
|
||||
from aiogram.types import User, Update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.database import get_user, get_db_connection_manager, _setup_db_connection
|
||||
from db.dal import user_dal
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ class JsonI18n:
|
||||
|
||||
def _load_locales(self):
|
||||
if not os.path.isdir(self.path):
|
||||
logging.error(f"Locales path not found: {self.path}")
|
||||
logging.error(
|
||||
f"Locales path not found or not a directory: {self.path}")
|
||||
return
|
||||
for item in os.listdir(self.path):
|
||||
if item.endswith(".json"):
|
||||
@@ -34,52 +35,73 @@ class JsonI18n:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
self.locales_data[lang_code] = json.load(f)
|
||||
except Exception as e:
|
||||
except json.JSONDecodeError as e_json_load:
|
||||
logging.error(
|
||||
f"Error loading locale {lang_code} from {file_path}: {e}"
|
||||
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}"
|
||||
)
|
||||
except Exception as e_load:
|
||||
logging.error(
|
||||
f"Error loading locale {lang_code} from {file_path}: {e_load}",
|
||||
exc_info=True)
|
||||
|
||||
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
|
||||
effective_lang_code = lang_code
|
||||
if not effective_lang_code or effective_lang_code not in self.locales_data:
|
||||
effective_lang_code = self.default_lang
|
||||
effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
|
||||
|
||||
lang_data = self.locales_data.get(effective_lang_code)
|
||||
if lang_data is None:
|
||||
logging.warning(
|
||||
f"No language data for '{effective_lang_code}' (default: '{self.default_lang}'). Key '{key}'."
|
||||
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
|
||||
)
|
||||
return key.format(**kwargs) if kwargs else key
|
||||
|
||||
text = lang_data.get(key)
|
||||
if text is None:
|
||||
if effective_lang_code != self.default_lang:
|
||||
default_lang_data = self.locales_data.get(
|
||||
self.default_lang, {})
|
||||
text = default_lang_data.get(key)
|
||||
if text is None: return key.format(**kwargs) if kwargs else key
|
||||
|
||||
if text is None:
|
||||
logging.warning(
|
||||
f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key."
|
||||
)
|
||||
return key.format(**kwargs) if kwargs else key
|
||||
try:
|
||||
return text.format(**kwargs) if kwargs else text
|
||||
except KeyError as e:
|
||||
except KeyError as e_format:
|
||||
logging.warning(
|
||||
f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'"
|
||||
f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'"
|
||||
)
|
||||
return text
|
||||
except Exception as e:
|
||||
except Exception as e_general_format:
|
||||
logging.error(
|
||||
f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'"
|
||||
)
|
||||
f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'",
|
||||
exc_info=True)
|
||||
return text
|
||||
|
||||
|
||||
_i18n_instance: Optional[JsonI18n] = None
|
||||
_i18n_instance_singleton: Optional[JsonI18n] = None
|
||||
|
||||
|
||||
def get_i18n_instance(path: str = "locales",
|
||||
default: str = "en",
|
||||
domain: str = "bot") -> JsonI18n:
|
||||
global _i18n_instance
|
||||
if _i18n_instance is None:
|
||||
_i18n_instance = JsonI18n(path=path, default=default, domain=domain)
|
||||
return _i18n_instance
|
||||
global _i18n_instance_singleton
|
||||
if _i18n_instance_singleton is None:
|
||||
|
||||
if not os.path.exists(path) or not os.path.isdir(path):
|
||||
logging.error(
|
||||
f"CRITICAL: Locales directory '{path}' not found. i18n will not work correctly."
|
||||
)
|
||||
|
||||
_i18n_instance_singleton = JsonI18n(path=path,
|
||||
default=default,
|
||||
domain=domain)
|
||||
else:
|
||||
_i18n_instance_singleton = JsonI18n(path=path,
|
||||
default=default,
|
||||
domain=domain)
|
||||
return _i18n_instance_singleton
|
||||
|
||||
|
||||
class I18nMiddleware(BaseMiddleware):
|
||||
@@ -92,65 +114,40 @@ class I18nMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
|
||||
Awaitable[Any]], event: Update,
|
||||
data: Dict[str, Any]) -> Any:
|
||||
session: AsyncSession = data["session"]
|
||||
event_user: Optional[User] = data.get("event_from_user")
|
||||
|
||||
current_language = self.i18n.default_lang
|
||||
|
||||
if event_user:
|
||||
logging.debug(
|
||||
f"I18nMiddleware: Processing for user {event_user.id}")
|
||||
|
||||
try:
|
||||
|
||||
user_db_data = await get_user(event_user.id)
|
||||
if user_db_data and user_db_data[
|
||||
'language_code'] and user_db_data[
|
||||
'language_code'] in self.i18n.locales_data:
|
||||
current_language = user_db_data['language_code']
|
||||
logging.debug(
|
||||
f"I18nMiddleware: User {event_user.id} language loaded from DB: {current_language}"
|
||||
)
|
||||
else:
|
||||
|
||||
if event_user.language_code:
|
||||
lang_prefix = event_user.language_code.split('-')[0]
|
||||
if lang_prefix in self.i18n.locales_data:
|
||||
current_language = lang_prefix
|
||||
logging.debug(
|
||||
f"I18nMiddleware: User {event_user.id} language set from Telegram client (prefix): {current_language}"
|
||||
)
|
||||
elif event_user.language_code in self.i18n.locales_data:
|
||||
current_language = event_user.language_code
|
||||
logging.debug(
|
||||
f"I18nMiddleware: User {event_user.id} language set from Telegram client (full): {current_language}"
|
||||
)
|
||||
else:
|
||||
|
||||
logging.debug(
|
||||
f"I18nMiddleware: User {event_user.id} Telegram client language '{event_user.language_code}' not supported. Using default: {current_language}"
|
||||
)
|
||||
else:
|
||||
logging.debug(
|
||||
f"I18nMiddleware: User {event_user.id} has no language_code from Telegram. Using default: {current_language}"
|
||||
)
|
||||
except Exception as e_db_lang:
|
||||
|
||||
logging.error(
|
||||
f"I18nMiddleware: Error fetching user language from DB for user {event_user.id}: {e_db_lang}. Falling back."
|
||||
)
|
||||
if event_user.language_code:
|
||||
lang_prefix = event_user.language_code.split('-')[0]
|
||||
user_db_model = await user_dal.get_user_by_id(
|
||||
session, event_user.id)
|
||||
if user_db_model and user_db_model.language_code and user_db_model.language_code in self.i18n.locales_data:
|
||||
current_language = user_db_model.language_code
|
||||
elif event_user.language_code:
|
||||
lang_prefix = event_user.language_code.split(
|
||||
'-')[0].lower()
|
||||
if lang_prefix in self.i18n.locales_data:
|
||||
current_language = lang_prefix
|
||||
elif event_user.language_code in self.i18n.locales_data:
|
||||
current_language = event_user.language_code
|
||||
elif event_user.language_code.lower(
|
||||
) in self.i18n.locales_data:
|
||||
current_language = event_user.language_code.lower()
|
||||
except Exception as e_db_lang:
|
||||
logging.error(
|
||||
f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.",
|
||||
exc_info=True)
|
||||
if event_user.language_code:
|
||||
lang_prefix = event_user.language_code.split(
|
||||
'-')[0].lower()
|
||||
if lang_prefix in self.i18n.locales_data:
|
||||
current_language = lang_prefix
|
||||
elif event_user.language_code.lower(
|
||||
) in self.i18n.locales_data:
|
||||
current_language = event_user.language_code.lower()
|
||||
|
||||
data["i18n_data"] = {
|
||||
"i18n_instance": self.i18n,
|
||||
"current_language": current_language
|
||||
}
|
||||
logging.debug(
|
||||
f"I18nMiddleware: Final current_language for event: {current_language}"
|
||||
)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -2,95 +2,115 @@ import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
from db.database import get_db_connection_manager
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .panel_api_service import PanelApiService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
async def send_expiration_warnings(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService):
|
||||
"""
|
||||
Sends expiration warnings to users whose subscriptions are ending soon.
|
||||
This function is called by the scheduler.
|
||||
"""
|
||||
panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
|
||||
logging.info(
|
||||
f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..."
|
||||
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
|
||||
)
|
||||
|
||||
sub_service = SubscriptionService(get_db_connection_manager, settings,
|
||||
panel_service)
|
||||
|
||||
expiring_subs = await sub_service.get_subscriptions_ending_soon(
|
||||
settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
|
||||
|
||||
if not expiring_subs:
|
||||
logging.info("No subscriptions found ending soon for notification.")
|
||||
if async_session_factory is None:
|
||||
logging.error(
|
||||
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
|
||||
)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Found {len(expiring_subs)} subscriptions ending soon for notification."
|
||||
)
|
||||
for sub_info in expiring_subs:
|
||||
user_id = sub_info['user_id']
|
||||
if not user_id:
|
||||
logging.warning(
|
||||
f"Skipping notification for subscription without user_id: {sub_info}"
|
||||
)
|
||||
continue
|
||||
|
||||
user_lang = sub_info.get('language_code') if sub_info.get(
|
||||
'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en')
|
||||
first_name = sub_info.get('first_name', 'User')
|
||||
end_date_str = sub_info['end_date_str']
|
||||
|
||||
days_left_float = sub_info.get('days_left')
|
||||
days_left_display = 'N/A'
|
||||
if days_left_float is not None:
|
||||
|
||||
days_left_display = max(0, int(round(days_left_float)))
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
message_text = _("subscription_ending_soon_notification",
|
||||
user_name=first_name,
|
||||
end_date=end_date_str,
|
||||
days_left=days_left_display)
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
|
||||
await sub_service.update_last_notification_sent(
|
||||
user_id, end_date_str)
|
||||
sub_service = SubscriptionService(settings, panel_service)
|
||||
|
||||
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
|
||||
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
|
||||
|
||||
if not expiring_subs_details_list:
|
||||
logging.info(
|
||||
"No subscriptions found ending soon for notification.")
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
|
||||
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
for sub_details in expiring_subs_details_list:
|
||||
user_id = sub_details['user_id']
|
||||
user_lang = sub_details.get('language_code',
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
first_name = sub_details.get('first_name', f"User {user_id}")
|
||||
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
|
||||
days_left_display = sub_details.get('days_left', "N/A")
|
||||
|
||||
subscription_actual_end_date_obj: Optional[
|
||||
datetime] = sub_details.get(
|
||||
'subscription_end_date_iso_for_update')
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
|
||||
)
|
||||
message_text = _("subscription_ending_soon_notification",
|
||||
user_name=first_name,
|
||||
end_date=end_date_str_for_msg,
|
||||
days_left=days_left_display)
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
logging.info(
|
||||
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
|
||||
)
|
||||
|
||||
if subscription_actual_end_date_obj:
|
||||
await sub_service.update_last_notification_sent(
|
||||
session, user_id, subscription_actual_end_date_obj)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"Finished processing expiration warnings. Session committed.")
|
||||
|
||||
except Exception as e_session:
|
||||
logging.error(
|
||||
f"Failed to send expiration warning to user {user_id}: {e}")
|
||||
await asyncio.sleep(0.1)
|
||||
f"Error during send_expiration_warnings session: {e_session}",
|
||||
exc_info=True)
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
"Session rolled back due to error in send_expiration_warnings."
|
||||
)
|
||||
|
||||
|
||||
async def schedule_subscription_notifications(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
scheduler: AsyncIOScheduler):
|
||||
"""Schedules the daily job to send expiration warnings."""
|
||||
async def schedule_subscription_notifications(
|
||||
bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
|
||||
async def job_wrapper():
|
||||
panel_service = PanelApiService(settings)
|
||||
try:
|
||||
|
||||
await send_expiration_warnings(bot, settings, i18n, panel_service)
|
||||
try:
|
||||
await send_expiration_warnings(bot, settings, i18n, panel_service,
|
||||
async_session_factory)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error in scheduled job 'send_expiration_warnings': {e}",
|
||||
f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
|
||||
exc_info=True)
|
||||
finally:
|
||||
await panel_service.close_session()
|
||||
|
||||
try:
|
||||
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
|
||||
@@ -98,7 +118,7 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
|
||||
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(
|
||||
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC."
|
||||
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
|
||||
)
|
||||
notification_hour = 9
|
||||
notification_minute = 0
|
||||
@@ -107,8 +127,9 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
|
||||
'cron',
|
||||
hour=notification_hour,
|
||||
minute=notification_minute,
|
||||
name="daily_subscription_expiration_warnings",
|
||||
misfire_grace_time=60 * 15)
|
||||
name="daily_subscription_expiration_warnings_v2",
|
||||
misfire_grace_time=60 * 15,
|
||||
replace_existing=True)
|
||||
logging.info(
|
||||
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
|
||||
)
|
||||
|
||||
+322
-141
@@ -1,9 +1,16 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import panel_sync_dal
|
||||
from db.models import PanelSyncStatus
|
||||
|
||||
|
||||
class PanelApiService:
|
||||
@@ -17,153 +24,304 @@ class PanelApiService:
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._session
|
||||
|
||||
async def close_session(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
logging.info("Panel API service session closed.")
|
||||
logging.info("Panel API service HTTP session closed.")
|
||||
|
||||
async def _prepare_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-For": self.default_client_ip,
|
||||
"X-Real-IP": self.default_client_ip,
|
||||
}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
return headers
|
||||
|
||||
async def _request(self, method: str, endpoint: str,
|
||||
async def _request(self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
log_full_response: bool = False,
|
||||
**kwargs) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
logging.error("Panel API URL not configured.")
|
||||
logging.error(
|
||||
"Panel API URL (PANEL_API_URL) not configured in settings.")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"message": "Panel API URL not configured."
|
||||
}
|
||||
|
||||
session = await self._get_session()
|
||||
aiohttp_session = await self._get_session()
|
||||
headers = await self._prepare_headers()
|
||||
|
||||
if "Authorization" not in headers and self.api_key:
|
||||
logging.warning(
|
||||
f"Authorization header missing for panel endpoint {endpoint} despite API key being set."
|
||||
)
|
||||
url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
|
||||
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
json_payload_for_log = kwargs.get('json') if method in [
|
||||
current_params = kwargs.get("params")
|
||||
url_with_params_for_log = url_for_request
|
||||
if current_params:
|
||||
try:
|
||||
url_with_params_for_log += "?" + urlencode(current_params)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
json_payload_for_log = kwargs.get('json') if method.upper() in [
|
||||
"POST", "PATCH", "PUT"
|
||||
] else None
|
||||
log_prefix = f"Panel API {method} {url}"
|
||||
log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
|
||||
if json_payload_for_log:
|
||||
log_prefix += f" Payload: {json_payload_for_log}"
|
||||
|
||||
try:
|
||||
payload_str = json.dumps(json_payload_for_log)
|
||||
log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
|
||||
except Exception:
|
||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||
try:
|
||||
async with session.request(method, url, headers=headers,
|
||||
**kwargs) as response:
|
||||
if 200 <= response.status < 300:
|
||||
async with aiohttp_session.request(method.upper(),
|
||||
url_for_request,
|
||||
headers=headers,
|
||||
**kwargs) as response:
|
||||
response_status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
log_suffix = f"| Status: {response_status}"
|
||||
|
||||
if log_full_response or not (200 <= response_status < 300):
|
||||
try:
|
||||
data = await response.json()
|
||||
logging.debug(
|
||||
f"{log_prefix} - Success ({response.status})")
|
||||
return data
|
||||
except aiohttp.ContentTypeError:
|
||||
logging.debug(
|
||||
f"{log_prefix} - Success ({response.status}) with non-JSON response."
|
||||
parsed_json_for_log = json.loads(response_text)
|
||||
pretty_response_text = json.dumps(parsed_json_for_log,
|
||||
indent=2,
|
||||
ensure_ascii=False)
|
||||
logging.info(
|
||||
f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logging.info(
|
||||
f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}"
|
||||
)
|
||||
else:
|
||||
logging.debug(
|
||||
f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}"
|
||||
)
|
||||
|
||||
if 200 <= response_status < 300:
|
||||
try:
|
||||
if 'application/json' in response.headers.get(
|
||||
'Content-Type', '').lower():
|
||||
data = json.loads(response_text)
|
||||
return data
|
||||
else:
|
||||
return {
|
||||
"status": "success",
|
||||
"code": response_status,
|
||||
"data_text": response_text
|
||||
}
|
||||
except json.JSONDecodeError as e_json_ok:
|
||||
logging.error(
|
||||
f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above."
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"code": response.status,
|
||||
"data_text": await response.text()
|
||||
"status": "success_parse_error",
|
||||
"code": response_status,
|
||||
"data_text": response_text,
|
||||
"parse_error": str(e_json_ok)
|
||||
}
|
||||
else:
|
||||
error_details = {
|
||||
"message":
|
||||
f"Request failed with status {response_status}",
|
||||
"raw_response_text": response_text
|
||||
}
|
||||
try:
|
||||
error_json = await response.json()
|
||||
logging.error(
|
||||
f"{log_prefix} - Failed ({response.status}): {error_json}"
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response.status,
|
||||
"response": error_json,
|
||||
"message": error_json.get("message"),
|
||||
"errorCode": error_json.get("errorCode")
|
||||
}
|
||||
except aiohttp.ContentTypeError:
|
||||
error_text = await response.text()
|
||||
logging.error(
|
||||
f"{log_prefix} - Failed ({response.status}): {error_text}"
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response.status,
|
||||
"message": error_text
|
||||
}
|
||||
if 'application/json' in response.headers.get(
|
||||
'Content-Type', '').lower():
|
||||
error_json_data = json.loads(response_text)
|
||||
error_details.update(error_json_data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response_status,
|
||||
"details": error_details
|
||||
}
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logging.error(
|
||||
f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -1,
|
||||
"message": f"Connection error: {str(e)}"
|
||||
}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.error(f"Panel API client request error to {url}: {e}")
|
||||
return {"error": True, "status_code": -1, "message": str(e)}
|
||||
except Exception as e:
|
||||
logging.error(f"Unexpected Panel API request error to {url}: {e}",
|
||||
exc_info=True)
|
||||
logging.error(f"Panel API ClientError to {url_for_request}: {e}")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -2,
|
||||
"message": f"Client error: {str(e)}"
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
logging.error(f"Panel API request to {url_for_request} timed out.")
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -3,
|
||||
"message": "Request timed out"
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Unexpected Panel API request error to {url_for_request}: {e}",
|
||||
exc_info=True)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": -4,
|
||||
"message": f"Unexpected error: {str(e)}"
|
||||
}
|
||||
|
||||
async def get_all_panel_users(
|
||||
self,
|
||||
page_size: int = 100,
|
||||
log_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
|
||||
all_users = []
|
||||
start_offset = 0
|
||||
while True:
|
||||
params = {"size": page_size, "start": start_offset}
|
||||
response_data = await self._request(
|
||||
"GET",
|
||||
"/users",
|
||||
params=params,
|
||||
log_full_response=log_responses)
|
||||
|
||||
if not response_data or response_data.get("error"):
|
||||
logging.error(
|
||||
f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}"
|
||||
)
|
||||
return None
|
||||
users_batch = response_data.get("response", {}).get("users", [])
|
||||
if not users_batch: break
|
||||
all_users.extend(users_batch)
|
||||
if len(users_batch) < page_size: break
|
||||
start_offset += page_size
|
||||
await asyncio.sleep(0.1)
|
||||
logging.info(f"Fetched {len(all_users)} users from panel API.")
|
||||
return all_users
|
||||
|
||||
async def get_user_by_uuid(
|
||||
self,
|
||||
user_uuid: str,
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
full_response = await self._request("GET",
|
||||
endpoint,
|
||||
log_full_response=log_response)
|
||||
if full_response and not full_response.get(
|
||||
"error") and "response" in full_response:
|
||||
return full_response.get("response")
|
||||
|
||||
return None
|
||||
|
||||
async def get_users_by_filter(
|
||||
self,
|
||||
username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Fetches users from panel by username."""
|
||||
if not username:
|
||||
logging.warning("get_users_by_filter called without username.")
|
||||
return None
|
||||
telegram_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
log_response: bool = True) -> Optional[List[Dict[str, Any]]]:
|
||||
|
||||
params = {"username": username}
|
||||
response_data = await self._request("GET", "/users", params=params)
|
||||
response_data = None
|
||||
filter_used_log = "No filter specified"
|
||||
|
||||
if response_data and not response_data.get("error"):
|
||||
users_list = response_data.get("response", {}).get("users", [])
|
||||
logging.info(
|
||||
f"Found {len(users_list)} panel users matching filter: {params}"
|
||||
if telegram_id is not None:
|
||||
filter_used_log = f"telegramId={telegram_id}"
|
||||
endpoint = f"/users/by-telegram-id/{telegram_id}"
|
||||
response_data = await self._request("GET",
|
||||
endpoint,
|
||||
log_full_response=log_response)
|
||||
|
||||
if response_data and not response_data.get(
|
||||
"error") and "response" in response_data and isinstance(
|
||||
response_data["response"], list):
|
||||
return response_data["response"]
|
||||
elif response_data and response_data.get("errorCode") == "A062":
|
||||
logging.info(
|
||||
f"Panel API: Users not found for {filter_used_log}")
|
||||
return []
|
||||
|
||||
elif username is not None:
|
||||
filter_used_log = f"username={username}"
|
||||
endpoint = f"/users/by-username/{username}"
|
||||
response_data = await self._request("GET",
|
||||
endpoint,
|
||||
log_full_response=log_response)
|
||||
|
||||
if response_data and not response_data.get(
|
||||
"error") and "response" in response_data and isinstance(
|
||||
response_data["response"], dict):
|
||||
return [response_data["response"]]
|
||||
elif response_data and response_data.get("errorCode") == "A062":
|
||||
logging.info(
|
||||
f"Panel API: User not found for {filter_used_log}")
|
||||
return []
|
||||
|
||||
elif email is not None:
|
||||
filter_used_log = f"email={email}"
|
||||
endpoint = f"/users/by-email/{email}"
|
||||
response_data = await self._request("GET",
|
||||
endpoint,
|
||||
log_full_response=log_response)
|
||||
|
||||
if response_data and not response_data.get(
|
||||
"error") and "response" in response_data and isinstance(
|
||||
response_data["response"], list):
|
||||
return response_data["response"]
|
||||
elif response_data and response_data.get("errorCode") == "A062":
|
||||
logging.info(
|
||||
f"Panel API: Users not found for {filter_used_log}")
|
||||
return []
|
||||
|
||||
if not telegram_id and not username and not email:
|
||||
logging.warning(
|
||||
"get_users_by_filter called without any specific filter criteria."
|
||||
)
|
||||
return users_list
|
||||
return []
|
||||
|
||||
logging.error(
|
||||
f"Failed to fetch panel users with filter {params}. Response: {response_data}"
|
||||
f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def create_panel_user(
|
||||
self,
|
||||
username: str,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
default_expire_days: int = 1,
|
||||
default_traffic_limit_bytes: int = 0,
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
specific_inbound_uuids: Optional[List[str]] = None,
|
||||
activate_all_inbounds_default_flag: bool = True
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
self,
|
||||
username_on_panel: str,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
default_expire_days: int = 1,
|
||||
default_traffic_limit_bytes: int = 0,
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
specific_inbound_uuids: Optional[List[str]] = None,
|
||||
activate_all_inbounds_default_flag: bool = True,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
status: str = "ACTIVE",
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not (6 <= len(username) <= 34
|
||||
and username.replace('_', '').replace('-', '').isalnum()):
|
||||
msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"response": {
|
||||
if not (6 <= len(username_on_panel) <= 34 and
|
||||
username_on_panel.replace('_', '').replace('-', '').isalnum()):
|
||||
if not (username_on_panel.startswith("tg_")
|
||||
and username_on_panel.split("tg_")[-1].isdigit()):
|
||||
msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
|
||||
logging.error(msg)
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 400,
|
||||
"message": msg,
|
||||
"errorCode": "VALIDATION_ERROR"
|
||||
"errorCode": "VALIDATION_ERROR_USERNAME"
|
||||
}
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expire_at_dt = now + timedelta(days=default_expire_days)
|
||||
@@ -171,83 +329,86 @@ class PanelApiService:
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"username": username,
|
||||
"username": username_on_panel,
|
||||
"status": status.upper(),
|
||||
"expireAt": expire_at_iso,
|
||||
"trafficLimitStrategy": default_traffic_limit_strategy,
|
||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||
}
|
||||
if specific_inbound_uuids:
|
||||
payload["activeUserInbounds"] = specific_inbound_uuids
|
||||
|
||||
payload["activateAllInbounds"] = False
|
||||
else:
|
||||
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
|
||||
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
if email: payload["email"] = email
|
||||
if description: payload["description"] = description
|
||||
if tag: payload["tag"] = tag
|
||||
|
||||
return await self._request("POST", "/users", json=payload)
|
||||
response = await self._request("POST",
|
||||
"/users",
|
||||
json=payload,
|
||||
log_full_response=log_response)
|
||||
if response and not response.get("error") and "response" in response:
|
||||
logging.info(
|
||||
f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response',{}).get('uuid')})."
|
||||
)
|
||||
return response
|
||||
|
||||
logging.error(
|
||||
f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
|
||||
)
|
||||
return response
|
||||
|
||||
async def update_user_details_on_panel(
|
||||
self, user_uuid: str,
|
||||
update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid
|
||||
|
||||
update_payload.pop('activateAllInbounds', None)
|
||||
self,
|
||||
user_uuid: str,
|
||||
update_payload: Dict[str, Any],
|
||||
log_response: bool = True) -> Optional[Dict[str, Any]]:
|
||||
if 'uuid' not in update_payload:
|
||||
update_payload['uuid'] = user_uuid
|
||||
|
||||
full_response = await self._request("PATCH",
|
||||
"/users",
|
||||
json=update_payload)
|
||||
json=update_payload,
|
||||
log_full_response=log_response)
|
||||
if full_response and not full_response.get(
|
||||
"error") and full_response.get("response"):
|
||||
"error") and "response" in full_response:
|
||||
logging.info(f"User {user_uuid} details updated on panel.")
|
||||
return full_response.get("response")
|
||||
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}"
|
||||
f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_all_panel_users(self,
|
||||
page_size: int = 100
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
all_users = []
|
||||
start_offset = 0
|
||||
while True:
|
||||
params = {"size": page_size, "start": start_offset}
|
||||
response_data = await self._request("GET", "/users", params=params)
|
||||
if not response_data or response_data.get("error"):
|
||||
logging.error(
|
||||
f"Failed to fetch panel users batch: {response_data}")
|
||||
return None
|
||||
users_batch = response_data.get("response", {}).get("users", [])
|
||||
if not users_batch: break
|
||||
all_users.extend(users_batch)
|
||||
if len(users_batch) < page_size: break
|
||||
start_offset += page_size
|
||||
logging.info(f"Fetched {len(all_users)} users from panel API.")
|
||||
return all_users
|
||||
async def update_user_status_on_panel(self,
|
||||
user_uuid: str,
|
||||
enable: bool,
|
||||
log_response: bool = True) -> bool:
|
||||
action = "enable" if enable else "disable"
|
||||
endpoint = f"/users/{user_uuid}/actions/{action}"
|
||||
response_data = await self._request("POST",
|
||||
endpoint,
|
||||
log_full_response=log_response)
|
||||
|
||||
async def get_user_by_uuid(self,
|
||||
user_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
full_response = await self._request("GET", f"/users/{user_uuid}")
|
||||
if full_response and not full_response.get(
|
||||
"error") and full_response.get("response"):
|
||||
return full_response.get("response")
|
||||
return None
|
||||
if response_data and not response_data.get(
|
||||
"error") and "response" in response_data:
|
||||
actual_status = response_data.get("response", {}).get("status")
|
||||
expected_status = "ACTIVE" if enable else "DISABLED"
|
||||
if actual_status == expected_status:
|
||||
logging.info(
|
||||
f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})."
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logging.warning(
|
||||
f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'."
|
||||
)
|
||||
return False
|
||||
|
||||
async def update_user_status_on_panel(self, user_uuid: str,
|
||||
enable: bool) -> bool:
|
||||
endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
|
||||
response_data = await self._request("POST", endpoint)
|
||||
if response_data and not response_data.get("error") and (
|
||||
response_data.get("response")
|
||||
or response_data.get("status") == "success"):
|
||||
logging.info(
|
||||
f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to update user {user_uuid} status on panel. Resp: {response_data}"
|
||||
f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -255,5 +416,25 @@ class PanelApiService:
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
client_type: Optional[str] = None) -> Optional[str]:
|
||||
if not self.settings.PANEL_API_URL: return None
|
||||
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
|
||||
if not self.settings.PANEL_API_URL:
|
||||
logging.error(
|
||||
"PANEL_API_URL not set, cannot generate subscription link.")
|
||||
return None
|
||||
base_sub_url = f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
|
||||
if client_type:
|
||||
return f"{base_sub_url}/{client_type.lower()}"
|
||||
return base_sub_url
|
||||
|
||||
async def update_bot_db_sync_status(self,
|
||||
session: AsyncSession,
|
||||
status: str,
|
||||
details: str,
|
||||
users_processed: int = 0,
|
||||
subs_synced: int = 0):
|
||||
await panel_sync_dal.update_panel_sync_status(session, status, details,
|
||||
users_processed,
|
||||
subs_synced)
|
||||
|
||||
async def get_bot_db_last_sync_status(
|
||||
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||
return await panel_sync_dal.get_panel_sync_status(session)
|
||||
|
||||
+137
-77
@@ -1,10 +1,12 @@
|
||||
import uuid
|
||||
import logging
|
||||
from yookassa import Configuration, Payment
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from yookassa import Configuration, Payment as YooKassaPayment
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
from yookassa.domain.common.confirmation_type import ConfirmationType
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -14,61 +16,84 @@ class YooKassaService:
|
||||
shop_id: Optional[str],
|
||||
secret_key: Optional[str],
|
||||
configured_return_url: Optional[str],
|
||||
bot_username_for_default: Optional[str] = None,
|
||||
bot_username_for_default_return: Optional[str] = None,
|
||||
settings_obj: Optional[Settings] = None):
|
||||
|
||||
self.settings = settings_obj
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
logging.warning(
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled."
|
||||
)
|
||||
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
||||
"Payment functionality will be DISABLED.")
|
||||
self.configured = False
|
||||
else:
|
||||
Configuration.account_id = shop_id
|
||||
Configuration.secret_key = secret_key
|
||||
self.configured = True
|
||||
logging.info(f"YooKassa configured for shop_id: {shop_id}")
|
||||
try:
|
||||
Configuration.configure(shop_id, secret_key)
|
||||
self.configured = True
|
||||
logging.info(
|
||||
f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to configure YooKassa SDK: {e}",
|
||||
exc_info=True)
|
||||
self.configured = False
|
||||
|
||||
if configured_return_url:
|
||||
self.return_url = configured_return_url
|
||||
elif bot_username_for_default:
|
||||
self.return_url = f"https://t.me/{bot_username_for_default}"
|
||||
elif bot_username_for_default_return:
|
||||
self.return_url = f"https://t.me/{bot_username_for_default_return}"
|
||||
logging.info(
|
||||
f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}"
|
||||
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}"
|
||||
)
|
||||
else:
|
||||
self.return_url = "https://example.com/payment_error_no_return_url"
|
||||
self.return_url = "https://example.com/payment_error_no_return_url_configured"
|
||||
logging.warning(
|
||||
f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}"
|
||||
f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
|
||||
f"Using placeholder: {self.return_url}. Payments may not complete correctly."
|
||||
)
|
||||
logging.info(
|
||||
f"YooKassa Service effective return_url: {self.return_url}")
|
||||
f"YooKassa Service effective return_url for payments: {self.return_url}"
|
||||
)
|
||||
|
||||
async def create_payment(
|
||||
self, amount: float, currency: str, description: str,
|
||||
metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("YooKassa is not configured. Cannot create payment.")
|
||||
return None
|
||||
|
||||
if not self.settings:
|
||||
logging.error(
|
||||
"YooKassaService: Settings object not available for receipt creation."
|
||||
)
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Service settings not initialized."
|
||||
}
|
||||
|
||||
if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
logging.error(
|
||||
"CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact."
|
||||
"YooKassaService: Settings object not available. Cannot create payment with receipt details."
|
||||
)
|
||||
return {
|
||||
"error":
|
||||
True,
|
||||
"internal_message":
|
||||
"YooKassa receipt email not configured by admin."
|
||||
"Service settings (Settings object) not initialized."
|
||||
}
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt["email"] = receipt_email
|
||||
elif receipt_phone:
|
||||
customer_contact_for_receipt["phone"] = receipt_phone
|
||||
elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
customer_contact_for_receipt[
|
||||
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
else:
|
||||
logging.error(
|
||||
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set."
|
||||
)
|
||||
return {
|
||||
"error":
|
||||
True,
|
||||
"internal_message":
|
||||
"YooKassa receipt customer contact (email/phone) missing and no default email configured."
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -79,15 +104,15 @@ class YooKassaService:
|
||||
})
|
||||
builder.set_capture(True)
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
receipt_items: List[Dict[str, Any]] = [{
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description":
|
||||
description,
|
||||
description[:128],
|
||||
"quantity":
|
||||
"1.00",
|
||||
"amount": {
|
||||
@@ -102,87 +127,122 @@ class YooKassaService:
|
||||
self.settings.YOOKASSA_PAYMENT_SUBJECT
|
||||
}]
|
||||
|
||||
receipt_customer: Dict[str, str] = {}
|
||||
if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
receipt_customer[
|
||||
"email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
if not receipt_customer:
|
||||
logging.error(
|
||||
"YooKassa: No customer contact (email/phone) for receipt.")
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Receipt customer contact missing."
|
||||
}
|
||||
|
||||
receipt_payload: Dict[str, Any] = {
|
||||
"customer": receipt_customer,
|
||||
"items": receipt_items
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_payload)
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
payment_request = builder.build()
|
||||
|
||||
logging.info(
|
||||
f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}"
|
||||
f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
|
||||
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
||||
)
|
||||
|
||||
res = Payment.create(payment_request, idempotence_key)
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request,
|
||||
idempotence_key))
|
||||
|
||||
logging.info(
|
||||
f"YooKassa Payment.create response: ID={res.id}, Status={res.status}"
|
||||
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id":
|
||||
res.id,
|
||||
response.id,
|
||||
"confirmation_url":
|
||||
res.confirmation.confirmation_url
|
||||
if res.confirmation else None,
|
||||
response.confirmation.confirmation_url
|
||||
if response.confirmation else None,
|
||||
"status":
|
||||
res.status,
|
||||
response.status,
|
||||
"metadata":
|
||||
res.metadata,
|
||||
response.metadata,
|
||||
"amount_value":
|
||||
float(res.amount.value),
|
||||
float(response.amount.value),
|
||||
"amount_currency":
|
||||
res.amount.currency,
|
||||
"idempotence_key":
|
||||
response.amount.currency,
|
||||
"idempotence_key_used":
|
||||
idempotence_key,
|
||||
"paid":
|
||||
res.paid,
|
||||
response.paid,
|
||||
"refundable":
|
||||
res.refundable,
|
||||
response.refundable,
|
||||
"created_at":
|
||||
res.created_at.isoformat() if hasattr(
|
||||
res.created_at, 'isoformat') else str(res.created_at)
|
||||
response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(
|
||||
response.created_at),
|
||||
"description_from_yk":
|
||||
response.description,
|
||||
"test_mode":
|
||||
response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(f"YooKassa payment creation failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(self,
|
||||
payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error(
|
||||
"YooKassa is not configured. Cannot get payment info.")
|
||||
return None
|
||||
try:
|
||||
payment_info = Payment.find_one(payment_id)
|
||||
if payment_info:
|
||||
logging.info(
|
||||
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
|
||||
if payment_info_yk:
|
||||
logging.info(
|
||||
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
|
||||
)
|
||||
return {
|
||||
"id": payment_info.id,
|
||||
"status": payment_info.status,
|
||||
"paid": payment_info.paid,
|
||||
"amount_value": float(payment_info.amount.value),
|
||||
"amount_currency": payment_info.amount.currency,
|
||||
"metadata": payment_info.metadata,
|
||||
"description": payment_info.description,
|
||||
"id":
|
||||
payment_info_yk.id,
|
||||
"status":
|
||||
payment_info_yk.status,
|
||||
"paid":
|
||||
payment_info_yk.paid,
|
||||
"amount_value":
|
||||
float(payment_info_yk.amount.value),
|
||||
"amount_currency":
|
||||
payment_info_yk.amount.currency,
|
||||
"metadata":
|
||||
payment_info_yk.metadata,
|
||||
"description":
|
||||
payment_info_yk.description,
|
||||
"refundable":
|
||||
payment_info_yk.refundable,
|
||||
"created_at":
|
||||
payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(
|
||||
payment_info_yk.created_at),
|
||||
"captured_at":
|
||||
payment_info_yk.captured_at.isoformat()
|
||||
if payment_info_yk.captured_at and hasattr(
|
||||
payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method_type":
|
||||
payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method else None,
|
||||
"test_mode":
|
||||
payment_info_yk.test
|
||||
if hasattr(payment_info_yk, 'test') else None
|
||||
}
|
||||
return None
|
||||
else:
|
||||
logging.warning(
|
||||
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"YooKassa get payment info for {payment_id} failed: {e}")
|
||||
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -1,88 +1,77 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Callable, Any, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from aiogram import Bot
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection
|
||||
|
||||
from db.dal import promo_code_dal, user_dal, subscription_dal
|
||||
from db.models import PromoCode, User
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
|
||||
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
|
||||
def __init__(self, settings: Settings,
|
||||
subscription_service: SubscriptionService, bot: Bot,
|
||||
i18n: JsonI18n):
|
||||
self.db_conn_provider = db_conn_provider
|
||||
self.settings = settings
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def apply_promo_code(self, user_id: int, code_input: str,
|
||||
async def apply_promo_code(self, session: AsyncSession, user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Applies a promo code for a user.
|
||||
Returns: (success_status: bool, message_text_for_user: str)
|
||||
"""
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
async with self.db_conn_provider() as db:
|
||||
await _setup_db_connection(db)
|
||||
try:
|
||||
promo_data = await get_promo_code_by_code(code_input_upper,
|
||||
db_conn=db)
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper)
|
||||
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found",
|
||||
code=code_input_upper)
|
||||
if not promo_data:
|
||||
return False, _("promo_code_not_found", code=code_input_upper)
|
||||
|
||||
if promo_data['current_activations'] >= promo_data[
|
||||
'max_activations']:
|
||||
return False, _("promo_code_max_activations_reached",
|
||||
code=code_input_upper)
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
return False, _("promo_code_already_used_by_user",
|
||||
code=code_input_upper)
|
||||
|
||||
active_sub = await self.subscription_service.get_active_subscription(
|
||||
user_id)
|
||||
if not active_sub:
|
||||
return False, _("promo_code_no_active_subscription")
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id)
|
||||
if not active_sub:
|
||||
return False, _("promo_code_no_active_subscription")
|
||||
|
||||
bonus_days = promo_data['bonus_days']
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
db_conn=db,
|
||||
reason=f"promo code {code_input_upper}")
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
reason=f"promo code {code_input_upper}")
|
||||
|
||||
if new_end_date:
|
||||
if new_end_date:
|
||||
|
||||
activation_success = await increment_promo_activation(
|
||||
promo_data['promo_code_id'],
|
||||
user_id,
|
||||
db_conn=db,
|
||||
payment_id=None)
|
||||
if activation_success:
|
||||
await db.commit()
|
||||
return True, _(
|
||||
"promo_code_applied_success",
|
||||
code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
new_end_date=new_end_date.strftime('%Y-%m-%d'))
|
||||
else:
|
||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||
session, promo_data.promo_code_id, user_id, payment_id=None)
|
||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
await db.rollback()
|
||||
return False, _("promo_code_invalid_or_expired")
|
||||
else:
|
||||
await db.rollback()
|
||||
return False, _("error_applying_promo_bonus")
|
||||
if activation_recorded and promo_incremented:
|
||||
|
||||
return True, _("promo_code_applied_success",
|
||||
code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
new_end_date=new_end_date.strftime('%Y-%m-%d'))
|
||||
else:
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error applying promo code {code_input_upper} for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
await db.rollback()
|
||||
return False, _("error_try_again")
|
||||
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}"
|
||||
)
|
||||
return False, _("error_applying_promo_bonus")
|
||||
else:
|
||||
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
@@ -1,48 +1,39 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from config.settings import Settings
|
||||
from db.database import get_user
|
||||
from db.dal import user_dal
|
||||
from db.models import User
|
||||
from db.dal import subscription_dal
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .subscription_service import SubscriptionService
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
|
||||
class ReferralService:
|
||||
|
||||
def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
|
||||
subscription_service_instance: 'SubscriptionService',
|
||||
bot: Bot, i18n: JsonI18n):
|
||||
self.db_conn_provider = db_conn_provider
|
||||
def __init__(self, settings: Settings,
|
||||
subscription_service: SubscriptionService, bot: Bot,
|
||||
i18n: JsonI18n):
|
||||
self.settings = settings
|
||||
self.subscription_service = subscription_service_instance
|
||||
self.subscription_service = subscription_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def process_new_user_referral(self, referee_user_id: int,
|
||||
inviter_user_id: Optional[int]):
|
||||
if inviter_user_id and referee_user_id != inviter_user_id:
|
||||
|
||||
logging.info(
|
||||
f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}."
|
||||
)
|
||||
|
||||
pass
|
||||
|
||||
async def apply_referral_bonuses_for_payment(
|
||||
self, referee_user_id: int, purchased_subscription_months: int,
|
||||
db_conn: aiosqlite.Connection) -> Dict[str, Any]:
|
||||
"""Applies bonuses. Notifies inviter. Returns referee bonus details."""
|
||||
self, session: AsyncSession, referee_user_id: int,
|
||||
purchased_subscription_months: int) -> Dict[str, Any]:
|
||||
|
||||
referee_final_end_date: Optional[datetime] = None
|
||||
referee_bonus_applied_days: Optional[int] = None
|
||||
inviter_bonus_successfully_applied = False
|
||||
|
||||
try:
|
||||
referee_user_row = await get_user(referee_user_id, db_conn=db_conn)
|
||||
if not referee_user_row or referee_user_row[
|
||||
'referred_by_id'] is None:
|
||||
referee_user_model = await user_dal.get_user_by_id(
|
||||
session, referee_user_id)
|
||||
if not referee_user_model or referee_user_model.referred_by_id is None:
|
||||
logging.debug(
|
||||
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
|
||||
)
|
||||
@@ -51,75 +42,177 @@ class ReferralService:
|
||||
"referee_new_end_date": None
|
||||
}
|
||||
|
||||
inviter_user_id = referee_user_row['referred_by_id']
|
||||
inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn)
|
||||
inviter_user_id = referee_user_model.referred_by_id
|
||||
inviter_user_model = await user_dal.get_user_by_id(
|
||||
session, inviter_user_id)
|
||||
|
||||
referee_name = referee_user_row[
|
||||
'first_name'] or f"User {referee_user_id}"
|
||||
referee_name_for_msg = referee_user_model.first_name or f"User {referee_user_id}"
|
||||
|
||||
default_lang_for_placeholder = getattr(self.settings,
|
||||
'DEFAULT_LANGUAGE', 'en')
|
||||
inviter_name = inviter_user_row[
|
||||
'first_name'] if inviter_user_row else self.i18n.gettext(
|
||||
default_lang_for_placeholder, "friend_placeholder")
|
||||
default_lang_for_placeholder = self.settings.DEFAULT_LANGUAGE
|
||||
inviter_name_for_referee_msg = (
|
||||
inviter_user_model.first_name if inviter_user_model
|
||||
and inviter_user_model.first_name else self.i18n.gettext(
|
||||
default_lang_for_placeholder, "friend_placeholder"))
|
||||
|
||||
inviter_bonus = self.settings.referral_bonus_inviter.get(
|
||||
inviter_bonus_days = self.settings.referral_bonus_inviter.get(
|
||||
purchased_subscription_months)
|
||||
referee_bonus = self.settings.referral_bonus_referee.get(
|
||||
referee_bonus_days = self.settings.referral_bonus_referee.get(
|
||||
purchased_subscription_months)
|
||||
|
||||
if inviter_bonus and inviter_bonus > 0 and inviter_user_row:
|
||||
new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral(
|
||||
user_id=inviter_user_id,
|
||||
bonus_days=inviter_bonus,
|
||||
db_conn=db_conn)
|
||||
if new_end_date_inviter:
|
||||
logging.info(
|
||||
f"Bonus applied for inviter {inviter_user_id}.")
|
||||
try:
|
||||
inviter_lang = inviter_user_row.get(
|
||||
'language_code', default_lang_for_placeholder)
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i("referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus,
|
||||
referee_name=referee_name,
|
||||
new_end_date=new_end_date_inviter.strftime(
|
||||
'%Y-%m-%d')))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e}"
|
||||
)
|
||||
else:
|
||||
if inviter_bonus_days and inviter_bonus_days > 0:
|
||||
if not inviter_user_model:
|
||||
|
||||
logging.warning(
|
||||
f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
|
||||
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus."
|
||||
)
|
||||
|
||||
if referee_bonus and referee_bonus > 0:
|
||||
new_end_date_referee = await self.subscription_service.extend_subscription_for_referral(
|
||||
user_id=referee_user_id,
|
||||
bonus_days=referee_bonus,
|
||||
db_conn=db_conn,
|
||||
is_referee_bonus=True)
|
||||
if new_end_date_referee:
|
||||
logging.info(
|
||||
f"Bonus applied for referee {referee_user_id}.")
|
||||
referee_final_end_date = new_end_date_referee
|
||||
referee_bonus_applied_days = referee_bonus
|
||||
else:
|
||||
|
||||
inviter_panel_uuid, inviter_panel_sub_link_id, _, _ = await self.subscription_service._get_or_create_panel_user_link_details(
|
||||
session, inviter_user_id, inviter_user_model)
|
||||
|
||||
if not inviter_panel_uuid:
|
||||
logging.warning(
|
||||
f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel."
|
||||
)
|
||||
|
||||
else:
|
||||
new_end_date_inviter = await self.subscription_service.extend_active_subscription_days(
|
||||
session=session,
|
||||
user_id=inviter_user_id,
|
||||
bonus_days=inviter_bonus_days,
|
||||
reason=f"referral bonus from {referee_name_for_msg}"
|
||||
)
|
||||
|
||||
if new_end_date_inviter:
|
||||
inviter_bonus_successfully_applied = True
|
||||
logging.info(
|
||||
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}."
|
||||
)
|
||||
|
||||
try:
|
||||
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i("referral_bonus_inviter_notification_extended",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=referee_name_for_msg,
|
||||
new_end_date=new_end_date_inviter.
|
||||
strftime('%Y-%m-%d')))
|
||||
except Exception as e_notify_inviter:
|
||||
logging.error(
|
||||
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
|
||||
)
|
||||
else:
|
||||
|
||||
logging.info(
|
||||
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
|
||||
)
|
||||
|
||||
bonus_start_date = datetime.now(timezone.utc)
|
||||
bonus_end_date = bonus_start_date + timedelta(
|
||||
days=inviter_bonus_days)
|
||||
|
||||
if not inviter_panel_sub_link_id:
|
||||
logging.error(
|
||||
f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch."
|
||||
)
|
||||
else:
|
||||
bonus_sub_payload = {
|
||||
"user_id":
|
||||
inviter_user_id,
|
||||
"panel_user_uuid":
|
||||
inviter_panel_uuid,
|
||||
"panel_subscription_uuid":
|
||||
inviter_panel_sub_link_id,
|
||||
"start_date":
|
||||
bonus_start_date,
|
||||
"end_date":
|
||||
bonus_end_date,
|
||||
"duration_months":
|
||||
0,
|
||||
"is_active":
|
||||
True,
|
||||
"status_from_panel":
|
||||
"ACTIVE_BONUS",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, inviter_panel_uuid,
|
||||
inviter_panel_sub_link_id)
|
||||
bonus_sub = await subscription_dal.upsert_subscription(
|
||||
session, bonus_sub_payload)
|
||||
|
||||
panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel(
|
||||
inviter_panel_uuid, {
|
||||
"expireAt":
|
||||
bonus_end_date.isoformat(
|
||||
timespec='milliseconds').
|
||||
replace('+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
})
|
||||
if panel_update_success:
|
||||
inviter_bonus_successfully_applied = True
|
||||
logging.info(
|
||||
f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}."
|
||||
)
|
||||
|
||||
inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
|
||||
_i = lambda k, **kw: self.i18n.gettext(
|
||||
inviter_lang, k, **kw)
|
||||
await self.bot.send_message(
|
||||
inviter_user_id,
|
||||
_i("referral_bonus_inviter_notification_new_sub",
|
||||
days=inviter_bonus_days,
|
||||
referee_name=
|
||||
referee_name_for_msg,
|
||||
new_end_date=bonus_end_date.
|
||||
strftime('%Y-%m-%d')))
|
||||
else:
|
||||
logging.warning(
|
||||
f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel."
|
||||
)
|
||||
|
||||
except Exception as e_create_bonus_sub:
|
||||
logging.error(
|
||||
f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}",
|
||||
exc_info=True)
|
||||
|
||||
if referee_bonus_days and referee_bonus_days > 0:
|
||||
|
||||
new_end_date_referee = await self.subscription_service.extend_active_subscription_days(
|
||||
session=session,
|
||||
user_id=referee_user_id,
|
||||
bonus_days=referee_bonus_days,
|
||||
reason=
|
||||
f"referee bonus (invited by {inviter_name_for_referee_msg})"
|
||||
)
|
||||
if new_end_date_referee:
|
||||
referee_final_end_date = new_end_date_referee
|
||||
referee_bonus_applied_days = referee_bonus_days
|
||||
logging.info(
|
||||
f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}."
|
||||
)
|
||||
else:
|
||||
|
||||
logging.warning(
|
||||
f"Failed to apply bonus subscription extension for referee {referee_user_id}."
|
||||
f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)."
|
||||
)
|
||||
|
||||
return {
|
||||
"referee_bonus_applied_days": referee_bonus_applied_days,
|
||||
"referee_new_end_date": referee_final_end_date
|
||||
"referee_new_end_date": referee_final_end_date,
|
||||
"inviter_bonus_applied_flag":
|
||||
inviter_bonus_successfully_applied
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",
|
||||
f"Error in apply_referral_bonuses_for_payment for referee {referee_user_id}: {e}",
|
||||
exc_info=True)
|
||||
|
||||
raise
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+111
-86
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import Field, field_validator, ValidationError
|
||||
from pydantic import Field, ValidationError, computed_field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
@@ -11,64 +11,67 @@ class Settings(BaseSettings):
|
||||
alias="ADMIN_IDS",
|
||||
description="Comma-separated list of admin Telegram User IDs")
|
||||
|
||||
DB_NAME: str = Field(default="bot_database.sqlite3")
|
||||
POSTGRES_USER: str = Field(default="user")
|
||||
POSTGRES_PASSWORD: str = Field(default="password")
|
||||
POSTGRES_HOST: str = Field(default="localhost")
|
||||
POSTGRES_PORT: int = Field(default=5432)
|
||||
POSTGRES_DB: str = Field(default="vpn_shop_db")
|
||||
|
||||
DEFAULT_LANGUAGE: str = Field(default="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
|
||||
|
||||
SUPPORT_LINK: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Link to support contact (e.g., t.me/your_support_contact)"
|
||||
)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Link to server status page (e.g., Uptime Kuma)")
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(
|
||||
default=None, description="Link to Terms of Use page")
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Default email for YooKassa receipts (REQUIRED for 54-FZ)")
|
||||
YOOKASSA_VAT_CODE: int = Field(
|
||||
default=1, description="YooKassa VAT code (check YooKassa docs!)")
|
||||
YOOKASSA_PAYMENT_MODE: str = Field(
|
||||
default="full_prepayment",
|
||||
description=
|
||||
"YooKassa payment mode (e.g., full_prepayment, full_payment)")
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(
|
||||
default="service",
|
||||
description="YooKassa payment subject (e.g., service, commodity)")
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
||||
YOOKASSA_VAT_CODE: int = Field(default=1)
|
||||
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
|
||||
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
PRICE_1_MONTH: Optional[int] = None
|
||||
PRICE_3_MONTHS: Optional[int] = None
|
||||
PRICE_6_MONTHS: Optional[int] = None
|
||||
PRICE_12_MONTHS: Optional[int] = None
|
||||
PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0)
|
||||
|
||||
REFERRAL_BONUS_DAYS_1_MONTH: Optional[int] = 3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS: Optional[int] = 7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS: Optional[int] = 15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS: Optional[int] = 30
|
||||
REFEREE_BONUS_DAYS_1_MONTH: Optional[int] = 1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS: Optional[int] = 3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS: Optional[int] = 7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS: Optional[int] = 15
|
||||
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
||||
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
|
||||
REFERRAL_BONUS_DAYS_INVITER_3_MONTHS: Optional[int] = Field(
|
||||
default=7, alias="REFERRAL_BONUS_DAYS_3_MONTHS")
|
||||
REFERRAL_BONUS_DAYS_INVITER_6_MONTHS: Optional[int] = Field(
|
||||
default=15, alias="REFERRAL_BONUS_DAYS_6_MONTHS")
|
||||
REFERRAL_BONUS_DAYS_INVITER_12_MONTHS: Optional[int] = Field(
|
||||
default=30, alias="REFERRAL_BONUS_DAYS_12_MONTHS")
|
||||
|
||||
REFERRAL_BONUS_DAYS_REFEREE_1_MONTH: Optional[int] = Field(
|
||||
default=1, alias="REFEREE_BONUS_DAYS_1_MONTH")
|
||||
REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS: Optional[int] = Field(
|
||||
default=3, alias="REFEREE_BONUS_DAYS_3_MONTHS")
|
||||
REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS: Optional[int] = Field(
|
||||
default=7, alias="REFEREE_BONUS_DAYS_6_MONTHS")
|
||||
REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field(
|
||||
default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS")
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
PANEL_API_KEY: Optional[str] = None
|
||||
PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1)
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0)
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(default=None)
|
||||
PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of inbounds to activate for new panel users")
|
||||
|
||||
TRIAL_ENABLED: bool = Field(default=True)
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
@@ -78,45 +81,42 @@ class Settings(BaseSettings):
|
||||
WEB_SERVER_PORT: int = Field(default=8080)
|
||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||
|
||||
_admin_ids_list: Optional[List[int]] = None
|
||||
@computed_field
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def ADMIN_IDS(self) -> List[int]:
|
||||
if self._admin_ids_list is None:
|
||||
if self.ADMIN_IDS_STR:
|
||||
try:
|
||||
self._admin_ids_list = [
|
||||
int(admin_id.strip())
|
||||
for admin_id in self.ADMIN_IDS_STR.split(',')
|
||||
if admin_id.strip().isdigit()
|
||||
]
|
||||
if not self._admin_ids_list and self.ADMIN_IDS_STR:
|
||||
logging.error(
|
||||
f"ADMIN_IDS_STR ('{self.ADMIN_IDS_STR}') contains non-integer values or is malformed. No admin IDs loaded from string."
|
||||
)
|
||||
self._admin_ids_list = []
|
||||
except ValueError:
|
||||
logging.error(
|
||||
f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers."
|
||||
)
|
||||
self._admin_ids_list = []
|
||||
else:
|
||||
self._admin_ids_list = []
|
||||
return self._admin_ids_list
|
||||
if self.ADMIN_IDS_STR:
|
||||
try:
|
||||
return [
|
||||
int(admin_id.strip())
|
||||
for admin_id in self.ADMIN_IDS_STR.split(',')
|
||||
if admin_id.strip().isdigit()
|
||||
]
|
||||
except ValueError:
|
||||
logging.error(
|
||||
f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers."
|
||||
)
|
||||
return []
|
||||
return []
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def PRIMARY_ADMIN_ID(self) -> Optional[int]:
|
||||
admin_ids_list = self.ADMIN_IDS
|
||||
if admin_ids_list:
|
||||
return admin_ids_list[0]
|
||||
return None
|
||||
ids = self.ADMIN_IDS
|
||||
return ids[0] if ids else None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trial_traffic_limit_bytes(self) -> int:
|
||||
if self.TRIAL_TRAFFIC_LIMIT_GB is None or self.TRIAL_TRAFFIC_LIMIT_GB <= 0:
|
||||
return 0
|
||||
return int(self.TRIAL_TRAFFIC_LIMIT_GB * (1024**3))
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]:
|
||||
if self.PANEL_USER_DEFAULT_INBOUND_UUIDS:
|
||||
@@ -127,49 +127,61 @@ class Settings(BaseSettings):
|
||||
]
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_webhook_path(self) -> str:
|
||||
|
||||
return "/webhook/yookassa"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_full_webhook_url(self) -> Optional[str]:
|
||||
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def subscription_options(self) -> Dict[int, int]:
|
||||
options: Dict[int, int] = {}
|
||||
if self.PRICE_1_MONTH is not None: options[1] = self.PRICE_1_MONTH
|
||||
if self.PRICE_3_MONTHS is not None: options[3] = self.PRICE_3_MONTHS
|
||||
if self.PRICE_6_MONTHS is not None: options[6] = self.PRICE_6_MONTHS
|
||||
if self.PRICE_12_MONTHS is not None: options[12] = self.PRICE_12_MONTHS
|
||||
def subscription_options(self) -> Dict[int, float]:
|
||||
options: Dict[int, float] = {}
|
||||
|
||||
if self.PRICE_1_MONTH is not None:
|
||||
options[1] = float(self.PRICE_1_MONTH / 100.0)
|
||||
if self.PRICE_3_MONTHS is not None:
|
||||
options[3] = float(self.PRICE_3_MONTHS / 100.0)
|
||||
if self.PRICE_6_MONTHS is not None:
|
||||
options[6] = float(self.PRICE_6_MONTHS / 100.0)
|
||||
if self.PRICE_12_MONTHS is not None:
|
||||
options[12] = float(self.PRICE_12_MONTHS / 100.0)
|
||||
return options
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||
bonuses: Dict[int, int] = {}
|
||||
if self.REFERRAL_BONUS_DAYS_1_MONTH is not None:
|
||||
bonuses[1] = self.REFERRAL_BONUS_DAYS_1_MONTH
|
||||
if self.REFERRAL_BONUS_DAYS_3_MONTHS is not None:
|
||||
bonuses[3] = self.REFERRAL_BONUS_DAYS_3_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_6_MONTHS is not None:
|
||||
bonuses[6] = self.REFERRAL_BONUS_DAYS_6_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_12_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None:
|
||||
bonuses[1] = self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH
|
||||
if self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS is not None:
|
||||
bonuses[3] = self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS is not None:
|
||||
bonuses[6] = self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def referral_bonus_referee(self) -> Dict[int, int]:
|
||||
bonuses: Dict[int, int] = {}
|
||||
if self.REFEREE_BONUS_DAYS_1_MONTH is not None:
|
||||
bonuses[1] = self.REFEREE_BONUS_DAYS_1_MONTH
|
||||
if self.REFEREE_BONUS_DAYS_3_MONTHS is not None:
|
||||
bonuses[3] = self.REFEREE_BONUS_DAYS_3_MONTHS
|
||||
if self.REFEREE_BONUS_DAYS_6_MONTHS is not None:
|
||||
bonuses[6] = self.REFEREE_BONUS_DAYS_6_MONTHS
|
||||
if self.REFEREE_BONUS_DAYS_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFEREE_BONUS_DAYS_12_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH is not None:
|
||||
bonuses[1] = self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS is not None:
|
||||
bonuses[3] = self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS is not None:
|
||||
bonuses[6] = self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS
|
||||
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
|
||||
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
model_config = SettingsConfigDict(env_file='.env',
|
||||
@@ -188,10 +200,23 @@ def get_settings() -> Settings:
|
||||
_settings_instance = Settings()
|
||||
if not _settings_instance.ADMIN_IDS:
|
||||
logging.warning(
|
||||
"CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. Admin functionality will be restricted."
|
||||
"CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. "
|
||||
"Admin functionality will be restricted.")
|
||||
|
||||
if not _settings_instance.PANEL_API_URL:
|
||||
logging.warning(
|
||||
"CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
|
||||
)
|
||||
if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(
|
||||
f"Pydantic validation error while loading settings: {e}")
|
||||
raise SystemExit(f"CRITICAL SETTINGS ERROR: {e}")
|
||||
|
||||
raise SystemExit(
|
||||
f"CRITICAL SETTINGS ERROR: {e}. Please check your .env file and Settings model."
|
||||
)
|
||||
return _settings_instance
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from ..models import MessageLog, User
|
||||
|
||||
|
||||
async def create_message_log(session: AsyncSession,
|
||||
log_data: dict) -> Optional[MessageLog]:
|
||||
|
||||
try:
|
||||
log_entry = await create_message_log_no_commit(session, log_data)
|
||||
await session.commit()
|
||||
await session.refresh(log_entry)
|
||||
return log_entry
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create and commit message log: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_message_logs(session: AsyncSession, limit: int,
|
||||
offset: int) -> List[MessageLog]:
|
||||
stmt = select(MessageLog).order_by(
|
||||
MessageLog.timestamp.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_message_logs(session: AsyncSession) -> int:
|
||||
stmt = select(func.count()).select_from(MessageLog)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_user_message_logs(session: AsyncSession, user_id_to_search: int,
|
||||
limit: int, offset: int) -> List[MessageLog]:
|
||||
stmt = (select(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id_to_search,
|
||||
MessageLog.target_user_id == user_id_to_search)).order_by(
|
||||
MessageLog.timestamp.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_user_message_logs(session: AsyncSession,
|
||||
user_id_to_search: int) -> int:
|
||||
stmt = (select(func.count()).select_from(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id_to_search,
|
||||
MessageLog.target_user_id == user_id_to_search)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def create_message_log_no_commit(session: AsyncSession,
|
||||
log_data: dict) -> MessageLog:
|
||||
|
||||
if log_data.get("target_user_id"):
|
||||
from .user_dal import get_user_by_id
|
||||
target_user = await get_user_by_id(session, log_data["target_user_id"])
|
||||
if not target_user:
|
||||
logging.warning(
|
||||
f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL."
|
||||
)
|
||||
log_data["target_user_id"] = None
|
||||
|
||||
new_log = MessageLog(**log_data)
|
||||
session.add(new_log)
|
||||
|
||||
logging.debug(
|
||||
f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}"
|
||||
)
|
||||
return new_log
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import update
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from db.models import PanelSyncStatus
|
||||
|
||||
SINGLETON_ID = 1
|
||||
|
||||
|
||||
async def get_panel_sync_status(
|
||||
session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||
return await session.get(PanelSyncStatus, SINGLETON_ID)
|
||||
|
||||
|
||||
async def update_panel_sync_status(
|
||||
session: AsyncSession,
|
||||
status: str,
|
||||
details: str,
|
||||
users_processed: int = 0,
|
||||
subs_synced: int = 0,
|
||||
last_sync_time: Optional[datetime] = None) -> PanelSyncStatus:
|
||||
if last_sync_time is None:
|
||||
last_sync_time = datetime.now(timezone.utc)
|
||||
|
||||
sync_record = await get_panel_sync_status(session)
|
||||
if sync_record:
|
||||
sync_record.last_sync_time = last_sync_time
|
||||
sync_record.status = status
|
||||
sync_record.details = details
|
||||
sync_record.users_processed_from_panel = users_processed
|
||||
sync_record.subscriptions_synced = subs_synced
|
||||
else:
|
||||
sync_record = PanelSyncStatus(
|
||||
id=SINGLETON_ID,
|
||||
last_sync_time=last_sync_time,
|
||||
status=status,
|
||||
details=details,
|
||||
users_processed_from_panel=users_processed,
|
||||
subscriptions_synced=subs_synced)
|
||||
session.add(sync_record)
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(sync_record)
|
||||
logging.info(
|
||||
f"Panel sync status updated: {status}, Users: {users_processed}, Subs: {subs_synced}"
|
||||
)
|
||||
return sync_record
|
||||
@@ -0,0 +1,115 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Payment, User
|
||||
|
||||
|
||||
async def create_payment_record(session: AsyncSession,
|
||||
payment_data: Dict[str, Any]) -> Payment:
|
||||
|
||||
from .user_dal import get_user_by_id
|
||||
user = await get_user_by_id(session, payment_data["user_id"])
|
||||
if not user:
|
||||
|
||||
raise ValueError(
|
||||
f"User with id {payment_data['user_id']} not found for creating payment."
|
||||
)
|
||||
|
||||
if payment_data.get("promo_code_id"):
|
||||
from .promo_code_dal import get_promo_code_by_id
|
||||
promo = await get_promo_code_by_id(session,
|
||||
payment_data["promo_code_id"])
|
||||
if not promo:
|
||||
raise ValueError(
|
||||
f"Promo code with id {payment_data['promo_code_id']} not found."
|
||||
)
|
||||
|
||||
new_payment = Payment(**payment_data)
|
||||
session.add(new_payment)
|
||||
await session.flush()
|
||||
await session.refresh(new_payment)
|
||||
logging.info(
|
||||
f"Payment record {new_payment.payment_id} created for user {new_payment.user_id}"
|
||||
)
|
||||
return new_payment
|
||||
|
||||
|
||||
async def get_payment_by_yookassa_id(
|
||||
session: AsyncSession, yookassa_payment_id: str) -> Optional[Payment]:
|
||||
stmt = select(Payment).where(
|
||||
Payment.yookassa_payment_id == yookassa_payment_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_db_id(session: AsyncSession,
|
||||
payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
stmt = select(Payment).where(Payment.payment_id == payment_db_id).options(
|
||||
selectinload(Payment.user), selectinload(Payment.promo_code_used))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_db_id_with_promo(
|
||||
session: AsyncSession, payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
stmt = select(Payment).where(Payment.payment_id == payment_db_id).options(
|
||||
selectinload(Payment.promo_code_used))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_payment_status_by_db_id(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
new_status: str,
|
||||
yk_payment_id: Optional[str] = None) -> Optional[Payment]:
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.updated_at = func.now()
|
||||
if yk_payment_id and payment.yookassa_payment_id is None:
|
||||
payment.yookassa_payment_id = yk_payment_id
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record {payment.payment_id} status updated to {new_status}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with DB ID {payment_db_id} not found for status update."
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def update_payment_status_by_yk_id(session: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
new_status: str) -> Optional[Payment]:
|
||||
payment = await get_payment_by_yookassa_id(session, yookassa_payment_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record with YK ID {yookassa_payment_id} status updated to {new_status}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with YK ID {yookassa_payment_id} not found for status update."
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_recent_payment_logs_with_user(session: AsyncSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0) -> List[Payment]:
|
||||
stmt = (select(Payment).options(selectinload(Payment.user)).order_by(
|
||||
Payment.created_at.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func, and_, or_
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from db.models import PromoCode, PromoCodeActivation, User, Payment
|
||||
|
||||
|
||||
async def create_promo_code(session: AsyncSession,
|
||||
promo_data: Dict[str, Any]) -> PromoCode:
|
||||
|
||||
new_promo = PromoCode(**promo_data)
|
||||
session.add(new_promo)
|
||||
await session.flush()
|
||||
await session.refresh(new_promo)
|
||||
logging.info(
|
||||
f"Promo code '{new_promo.code}' created with ID {new_promo.promo_code_id}"
|
||||
)
|
||||
return new_promo
|
||||
|
||||
|
||||
async def get_promo_code_by_id(session: AsyncSession,
|
||||
promo_code_id: int) -> Optional[PromoCode]:
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(), PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(session: AsyncSession,
|
||||
limit: int = 20,
|
||||
offset: int = 0) -> List[PromoCode]:
|
||||
stmt = (select(PromoCode).where(
|
||||
PromoCode.is_active == True,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until
|
||||
> datetime.now(timezone.utc))).order_by(
|
||||
PromoCode.created_at.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def increment_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
if promo:
|
||||
if promo.current_activations < promo.max_activations:
|
||||
promo.current_activations += 1
|
||||
await session.flush()
|
||||
await session.refresh(promo)
|
||||
return promo
|
||||
else:
|
||||
logging.warning(
|
||||
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_activation_for_promo(
|
||||
session: AsyncSession, promo_code_id: int,
|
||||
user_id: int) -> Optional[PromoCodeActivation]:
|
||||
|
||||
stmt = select(PromoCodeActivation).where(
|
||||
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||
PromoCodeActivation.user_id == user_id).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_promo_activation(
|
||||
session: AsyncSession,
|
||||
promo_code_id: int,
|
||||
user_id: int,
|
||||
payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]:
|
||||
|
||||
existing_activation = await get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
logging.info(
|
||||
f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}"
|
||||
)
|
||||
return existing_activation
|
||||
|
||||
from .user_dal import get_user_by_id
|
||||
user = await get_user_by_id(session, user_id)
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
if not user or not promo:
|
||||
logging.error(
|
||||
f"Cannot record promo activation: User {user_id} or Promo {promo_code_id} not found."
|
||||
)
|
||||
return None
|
||||
|
||||
if payment_id:
|
||||
from .payment_dal import get_payment_by_db_id
|
||||
payment = await get_payment_by_db_id(session, payment_id)
|
||||
if not payment:
|
||||
logging.error(
|
||||
f"Cannot record promo activation: Payment {payment_id} not found."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
activation_data = {
|
||||
"promo_code_id": promo_code_id,
|
||||
"user_id": user_id,
|
||||
"payment_id": payment_id,
|
||||
"activated_at": datetime.now(timezone.utc)
|
||||
}
|
||||
new_activation = PromoCodeActivation(**activation_data)
|
||||
session.add(new_activation)
|
||||
await session.flush()
|
||||
await session.refresh(new_activation)
|
||||
logging.info(
|
||||
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
|
||||
)
|
||||
return new_activation
|
||||
@@ -0,0 +1,205 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy import update, delete, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from db.models import Subscription, User
|
||||
|
||||
|
||||
async def get_active_subscription_by_user_id(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc))
|
||||
if panel_user_uuid:
|
||||
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||
stmt = stmt.order_by(Subscription.end_date.desc())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_subscription_by_panel_subscription_uuid(
|
||||
session: AsyncSession, panel_sub_uuid: str) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.panel_subscription_uuid == panel_sub_uuid)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_subscription(session: AsyncSession,
|
||||
sub_data: Dict[str, Any]) -> Subscription:
|
||||
from .user_dal import get_user_by_id
|
||||
|
||||
if "user_id" not in sub_data or sub_data["user_id"] is None:
|
||||
raise ValueError(
|
||||
"user_id is required to create a subscription directly.")
|
||||
user = await get_user_by_id(session, sub_data["user_id"])
|
||||
if not user:
|
||||
raise ValueError(
|
||||
f"User with id {sub_data['user_id']} not found for creating subscription."
|
||||
)
|
||||
|
||||
new_sub = Subscription(**sub_data)
|
||||
session.add(new_sub)
|
||||
await session.flush()
|
||||
await session.refresh(new_sub)
|
||||
logging.info(
|
||||
f"Subscription {new_sub.subscription_id} created for user {new_sub.user_id}"
|
||||
)
|
||||
return new_sub
|
||||
|
||||
|
||||
async def update_subscription(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[Subscription]:
|
||||
sub = await session.get(Subscription, subscription_id)
|
||||
if sub:
|
||||
for key, value in update_data.items():
|
||||
setattr(sub, key, value)
|
||||
await session.flush()
|
||||
await session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
async def upsert_subscription(session: AsyncSession,
|
||||
sub_payload: Dict[str, Any]) -> Subscription:
|
||||
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
|
||||
if not panel_sub_uuid:
|
||||
raise ValueError("panel_subscription_uuid is required for upsert.")
|
||||
|
||||
existing_sub = await get_subscription_by_panel_subscription_uuid(
|
||||
session, panel_sub_uuid)
|
||||
|
||||
if existing_sub:
|
||||
logging.info(
|
||||
f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}"
|
||||
)
|
||||
for key, value in sub_payload.items():
|
||||
if hasattr(existing_sub, key):
|
||||
setattr(existing_sub, key, value)
|
||||
await session.flush()
|
||||
await session.refresh(existing_sub)
|
||||
return existing_sub
|
||||
else:
|
||||
logging.info(
|
||||
f"Creating new subscription with panel_sub_uuid {panel_sub_uuid}")
|
||||
|
||||
if sub_payload.get(
|
||||
"user_id") is None and "panel_user_uuid" not in sub_payload:
|
||||
raise ValueError(
|
||||
"For a new subscription without user_id, panel_user_uuid is required."
|
||||
)
|
||||
if "end_date" not in sub_payload:
|
||||
raise ValueError("Missing 'end_date' for new subscription.")
|
||||
if sub_payload.get("user_id") is not None:
|
||||
from .user_dal import get_user_by_id
|
||||
user = await get_user_by_id(session, sub_payload["user_id"])
|
||||
if not user:
|
||||
raise ValueError(
|
||||
f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}."
|
||||
)
|
||||
|
||||
new_sub = Subscription(**sub_payload)
|
||||
session.add(new_sub)
|
||||
await session.flush()
|
||||
await session.refresh(new_sub)
|
||||
return new_sub
|
||||
|
||||
|
||||
async def deactivate_other_active_subscriptions(
|
||||
session: AsyncSession, panel_user_uuid: str,
|
||||
current_panel_subscription_uuid: Optional[str]):
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.panel_user_uuid == panel_user_uuid,
|
||||
Subscription.is_active == True,
|
||||
).values(is_active=False, status_from_panel="INACTIVE_BY_BOT_SYNC"))
|
||||
if current_panel_subscription_uuid:
|
||||
stmt = stmt.where(Subscription.panel_subscription_uuid !=
|
||||
current_panel_subscription_uuid)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
logging.info(
|
||||
f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}."
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_end_date(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
new_end_date: datetime) -> Optional[Subscription]:
|
||||
|
||||
return await update_subscription(
|
||||
session, subscription_id, {
|
||||
"end_date": new_end_date,
|
||||
"last_notification_sent": None,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_EXTENDED_BY_BOT"
|
||||
})
|
||||
|
||||
|
||||
async def has_any_subscription_for_user(session: AsyncSession,
|
||||
user_id: int) -> bool:
|
||||
stmt = select(Subscription.subscription_id).where(
|
||||
Subscription.user_id == user_id).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def get_subscriptions_near_expiration(
|
||||
session: AsyncSession, days_threshold: int) -> List[Subscription]:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
|
||||
stmt = (select(Subscription).join(Subscription.user).where(
|
||||
Subscription.is_active == True, Subscription.end_date > now_utc,
|
||||
Subscription.end_date <= threshold_date,
|
||||
or_(
|
||||
Subscription.last_notification_sent == None,
|
||||
func.date(Subscription.last_notification_sent)
|
||||
< func.date(now_utc))).order_by(
|
||||
Subscription.end_date.asc()).options(
|
||||
selectinload(Subscription.user)))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_subscription_notification_time(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
notification_time: datetime) -> Optional[Subscription]:
|
||||
return await update_subscription(
|
||||
session, subscription_id,
|
||||
{"last_notification_sent": notification_time})
|
||||
|
||||
|
||||
async def get_user_active_subscription_end_date_str(
|
||||
session: AsyncSession, user_id: int) -> Optional[str]:
|
||||
stmt = (select(Subscription.end_date).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc)).order_by(
|
||||
Subscription.end_date.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
end_date_obj = result.scalar_one_or_none()
|
||||
return end_date_obj.strftime('%Y-%m-%d') if end_date_obj else None
|
||||
|
||||
|
||||
async def find_subscription_for_notification_update(
|
||||
session: AsyncSession, user_id: int,
|
||||
subscription_end_date_to_match: datetime) -> Optional[Subscription]:
|
||||
|
||||
if subscription_end_date_to_match.tzinfo is None:
|
||||
subscription_end_date_to_match = subscription_end_date_to_match.replace(
|
||||
tzinfo=timezone.utc)
|
||||
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date
|
||||
>= subscription_end_date_to_match - timedelta(seconds=1),
|
||||
Subscription.end_date
|
||||
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,124 @@
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import User, Subscription
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession,
|
||||
user_id: int) -> Optional[User]:
|
||||
stmt = select(User).where(User.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession,
|
||||
username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip('@').lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_panel_uuid(session: AsyncSession,
|
||||
panel_uuid: str) -> Optional[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str,
|
||||
Any]) -> User:
|
||||
|
||||
if 'registration_date' not in user_data:
|
||||
user_data['registration_date'] = datetime.now()
|
||||
|
||||
new_user = User(**user_data)
|
||||
session.add(new_user)
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
logging.info(
|
||||
f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}."
|
||||
)
|
||||
return new_user
|
||||
|
||||
|
||||
async def update_user(session: AsyncSession, user_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[User]:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def update_user_language(session: AsyncSession, user_id: int,
|
||||
lang_code: str) -> bool:
|
||||
stmt = update(User).where(User.user_id == user_id).values(
|
||||
language_code=lang_code)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def set_user_ban_status(session: AsyncSession, user_id: int,
|
||||
is_banned: bool) -> bool:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
user.is_banned = is_banned
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_banned_users_paginated(session: AsyncSession, limit: int,
|
||||
offset: int) -> Tuple[List[User], int]:
|
||||
stmt_users = select(User).where(User.is_banned == True).order_by(
|
||||
User.registration_date.desc()).limit(limit).offset(offset)
|
||||
result_users = await session.execute(stmt_users)
|
||||
users_list = result_users.scalars().all()
|
||||
|
||||
stmt_count = select(
|
||||
func.count()).select_from(User).where(User.is_banned == True)
|
||||
result_count = await session.execute(stmt_count)
|
||||
total_banned = result_count.scalar_one()
|
||||
|
||||
return users_list, total_banned
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(
|
||||
session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
|
||||
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
||||
banned_users_stmt = select(func.count(
|
||||
User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
|
||||
active_subs_stmt = (select(func.count(
|
||||
func.distinct(Subscription.user_id))).join(
|
||||
User, Subscription.user_id == User.user_id).where(
|
||||
Subscription.is_active == True).where(
|
||||
Subscription.end_date > datetime.now()))
|
||||
|
||||
total_users = (await
|
||||
session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (
|
||||
await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
active_subs_users = (
|
||||
await session.execute(active_subs_stmt)).scalar_one_or_none() or 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"users_with_active_subscriptions": active_subs_users,
|
||||
}
|
||||
-1081
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
|
||||
async_engine = None
|
||||
|
||||
|
||||
def init_db_connection(settings: Settings) -> sessionmaker:
|
||||
global async_engine
|
||||
|
||||
if async_engine is None:
|
||||
logging.info(
|
||||
f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}"
|
||||
)
|
||||
async_engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
local_async_session_factory = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
logging.info(
|
||||
f"SQLAlchemy Async Engine and SessionFactory configured for PostgreSQL."
|
||||
)
|
||||
return local_async_session_factory
|
||||
|
||||
|
||||
async def get_async_session(session_factory: sessionmaker) -> AsyncSession:
|
||||
|
||||
if session_factory is None:
|
||||
raise RuntimeError(
|
||||
"AsyncSessionFactory is not provided or initialized.")
|
||||
|
||||
async_session = session_factory()
|
||||
try:
|
||||
yield async_session
|
||||
finally:
|
||||
await async_session.close()
|
||||
|
||||
|
||||
async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
|
||||
global async_engine
|
||||
if async_engine is None:
|
||||
|
||||
logging.warning(
|
||||
"init_db: async_engine was None, re-initializing via init_db_connection."
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
"async_engine is not initialized. Call init_db_connection and get session_factory first."
|
||||
)
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status
|
||||
try:
|
||||
current_status = await get_panel_sync_status(session)
|
||||
if current_status is None:
|
||||
logging.info("Initializing panel_sync_status record.")
|
||||
await update_panel_sync_status(session,
|
||||
status="never_run",
|
||||
details="System initialized",
|
||||
users_processed=0,
|
||||
subs_synced=0)
|
||||
await session.commit()
|
||||
except Exception as e_sync_init:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to initialize PanelSyncStatus: {e_sync_init}",
|
||||
exc_info=True)
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, ForeignKey, UniqueConstraint, Text, BigInteger
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(BigInteger, primary_key=True, index=True)
|
||||
username = Column(String, nullable=True, index=True)
|
||||
first_name = Column(String, nullable=True)
|
||||
last_name = Column(String, nullable=True)
|
||||
language_code = Column(String, default="ru")
|
||||
registration_date = Column(DateTime(timezone=True),
|
||||
server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True)
|
||||
|
||||
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
||||
subscriptions = relationship("Subscription",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan")
|
||||
payments = relationship("Payment",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan")
|
||||
promo_code_activations = relationship("PromoCodeActivation",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan")
|
||||
message_logs_authored = relationship("MessageLog",
|
||||
foreign_keys="MessageLog.user_id",
|
||||
back_populates="author_user",
|
||||
cascade="all, delete-orphan")
|
||||
message_logs_targeted = relationship(
|
||||
"MessageLog",
|
||||
foreign_keys="MessageLog.target_user_id",
|
||||
back_populates="target_user",
|
||||
cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(user_id={self.user_id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
subscription_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=False,
|
||||
index=True)
|
||||
panel_user_uuid = Column(String, nullable=False, index=True)
|
||||
panel_subscription_uuid = Column(String,
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=True)
|
||||
start_date = Column(DateTime(timezone=True), nullable=True)
|
||||
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
duration_months = Column(Integer, nullable=True)
|
||||
is_active = Column(Boolean, default=True, index=True)
|
||||
status_from_panel = Column(String, nullable=True)
|
||||
traffic_limit_bytes = Column(BigInteger, nullable=True)
|
||||
traffic_used_bytes = Column(BigInteger, nullable=True)
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="subscriptions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription(id={self.subscription_id}, user_id={self.user_id}, panel_uuid='{self.panel_user_uuid}', ends='{self.end_date}')>"
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = "payments"
|
||||
|
||||
payment_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=False,
|
||||
index=True)
|
||||
yookassa_payment_id = Column(String,
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
currency = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
subscription_duration_months = Column(Integer, nullable=True)
|
||||
promo_code_id = Column(Integer,
|
||||
ForeignKey("promo_codes.promo_code_id"),
|
||||
nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
onupdate=func.now(),
|
||||
nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="payments")
|
||||
promo_code_used = relationship("PromoCode",
|
||||
back_populates="payments_where_used")
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promo_codes"
|
||||
|
||||
promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String, unique=True, nullable=False, index=True)
|
||||
bonus_days = Column(Integer, nullable=False)
|
||||
max_activations = Column(Integer, nullable=False)
|
||||
current_activations = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_by_admin_id = Column(BigInteger, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
valid_until = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
activations = relationship("PromoCodeActivation",
|
||||
back_populates="promo_code",
|
||||
cascade="all, delete-orphan")
|
||||
payments_where_used = relationship("Payment",
|
||||
back_populates="promo_code_used")
|
||||
|
||||
|
||||
class PromoCodeActivation(Base):
|
||||
__tablename__ = "promo_code_activations"
|
||||
|
||||
activation_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
promo_code_id = Column(Integer,
|
||||
ForeignKey("promo_codes.promo_code_id"),
|
||||
nullable=False)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False)
|
||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
payment_id = Column(Integer,
|
||||
ForeignKey("payments.payment_id"),
|
||||
nullable=True)
|
||||
|
||||
promo_code = relationship("PromoCode", back_populates="activations")
|
||||
user = relationship("User", back_populates="promo_code_activations")
|
||||
payment = relationship("Payment")
|
||||
|
||||
__table_args__ = (UniqueConstraint('promo_code_id',
|
||||
'user_id',
|
||||
name='uq_promo_user_activation'), )
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
log_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True,
|
||||
index=True)
|
||||
telegram_username = Column(String, nullable=True)
|
||||
telegram_first_name = Column(String, nullable=True)
|
||||
event_type = Column(String, nullable=False, index=True)
|
||||
content = Column(Text, nullable=True)
|
||||
raw_update_preview = Column(Text, nullable=True)
|
||||
timestamp = Column(DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
index=True)
|
||||
is_admin_event = Column(Boolean, default=False)
|
||||
target_user_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True,
|
||||
index=True)
|
||||
|
||||
author_user = relationship("User",
|
||||
foreign_keys=[user_id],
|
||||
back_populates="message_logs_authored")
|
||||
target_user = relationship("User",
|
||||
foreign_keys=[target_user_id],
|
||||
back_populates="message_logs_targeted")
|
||||
|
||||
|
||||
class PanelSyncStatus(Base):
|
||||
__tablename__ = "panel_sync_status"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1, autoincrement=False)
|
||||
last_sync_time = Column(DateTime(timezone=True), nullable=True)
|
||||
status = Column(String, nullable=True)
|
||||
details = Column(Text, nullable=True)
|
||||
users_processed_from_panel = Column(Integer, default=0)
|
||||
subscriptions_synced = Column(Integer, default=0)
|
||||
|
||||
__table_args__ = (UniqueConstraint('id'), )
|
||||
+172
-155
@@ -1,188 +1,205 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать, {user_name}!",
|
||||
"choose_language": "Пожалуйста, выберите ваш язык:",
|
||||
"language_set": "Язык установлен на <b>Русский</b>.",
|
||||
"language_set_alert": "Язык обновлен!",
|
||||
"main_menu_greeting": "Привет, {user_name}!\nЧто бы вы хотели сделать?",
|
||||
"error_service_unavailable": "⚠️ Сервис временно недоступен. Попробуйте позже.",
|
||||
"error_occurred_processing_request": "⚠️ Произошла ошибка. Попробуйте еще раз.",
|
||||
"unknown_command_private": "Неизвестная команда. Используйте /start.",
|
||||
"error_try_again": "Произошла ошибка. Попробуйте снова.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "📆 Моя подписка",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"menu_apply_promo_button": "🎁 Промокод",
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"menu_apply_promo_button": "🎟 Промокод",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
|
||||
"choose_language": "Выберите язык / Select language:",
|
||||
"language_set_alert": "Язык изменен!",
|
||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
|
||||
"no_subscription_options_available": "Варианты подписки сейчас недоступны.",
|
||||
"select_subscription_period": "Выберите период подписки:",
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Подписка на <b>{months} мес.</b> за <b>{price} {currency_symbol}</b>.\n\nПерейти к оплате?",
|
||||
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_service_unavailable": "⚠️ Платежный сервис недоступен. Попробуйте позже.",
|
||||
"error_creating_payment_record": "⚠️ Ошибка подготовки платежа. Попробуйте снова.",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
"payment_service_unavailable_alert": "Платежный сервис недоступен",
|
||||
"error_creating_payment_record": "Ошибка при создании записи о платеже. Попробуйте позже.",
|
||||
"error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"error_payment_gateway": "⚠️ Ошибка платежного шлюза. Попробуйте или напишите в поддержку.",
|
||||
"payment_successful": "✅ Оплата успешна! Ваша подписка на <b>{months} мес.</b> активна до <b>{end_date}</b>.",
|
||||
"payment_failed": "❌ Оплата не удалась/отменена. Если деньги списаны, напишите в поддержку.",
|
||||
"payment_description_subscription": "Подписка на {months} мес.",
|
||||
"my_subscription_details": "ℹ️ <b>Ваша подписка</b>\n\n🗓️ До: <b>{end_date}</b> (Дней осталось: {days_left})\n\n🔗 Ссылка: <code>{config_link}</code>\n\n📊 Трафик: {traffic_used} / {traffic_limit}\n💡 Статус: {status}",
|
||||
"subscription_not_active": "Активной подписки нет. Оформите сейчас!",
|
||||
"config_link_not_available": "Н/Д (Попробуйте /sync или поддержка)",
|
||||
"traffic_unlimited": "Безлимит",
|
||||
"traffic_na": "Н/Д",
|
||||
"error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.",
|
||||
"payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.",
|
||||
"payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.",
|
||||
"payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.",
|
||||
|
||||
"my_subscription_details": "<b>🔐 Моя подписка:</b>\n\nСтатус: <b>{status}</b>\nАктивна до: <b>{end_date}</b> (осталось дней: {days_left})\n\nТрафик: <b>{traffic_used}</b> из <b>{traffic_limit}</b>\n\nСсылка на конфигурацию:\n<code>{config_link}</code>",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
"subscription_not_active": "У вас нет активной подписки. \nХотите приобрести?",
|
||||
"status_active": "Активна",
|
||||
"status_expired": "Истекла",
|
||||
"status_disabled": "Отключена",
|
||||
"status_limited": "Ограничена",
|
||||
"status_unknown": "Неизвестен",
|
||||
"subscription_ending_soon_notification": "{user_name},\nВаша подписка истекает <b>{end_date}</b> (дней осталось: {days_left}).\nНе забудьте продлить!",
|
||||
"payment_successful_with_referral_bonus": "✅ Оплата на <b>{months} мес.</b> успешна! Базовая дата: {base_end_date}.\n\n🎉 Вас пригласил(а) {inviter_name}, вы получили дополнительно <b>{bonus_days} бонусных дней</b>!\nИтоговая дата окончания: <b>{final_end_date}</b>.",
|
||||
"payment_successful_error_details": "Оплата прошла успешно, но возникла ошибка отображения деталей подписки. Пожалуйста, проверьте раздел 'Моя подписка' или свяжитесь с поддержкой.",
|
||||
"error_processing_your_payment": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.",
|
||||
|
||||
"trial_confirm_prompt": "Активировать бесплатный пробный период на <b>{days} дней</b> с лимитом трафика <b>{traffic_gb} ГБ</b>?\n\n<i>Это можно сделать только один раз, если у вас ранее не было подписок.</i>",
|
||||
"trial_confirm_activate_button": "✅ Активировать!",
|
||||
"trial_already_had_subscription_or_trial": "Пробный период доступен только если у вас ранее не было никаких подписок (включая пробные).",
|
||||
"trial_activation_failed": "⚠️ К сожалению, не удалось активировать ваш пробный период. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"trial_feature_disabled": "Пробные периоды в данный момент недоступны.",
|
||||
"trial_cancelled": "Активация пробного периода отменена.",
|
||||
"trial_activated_alert": "✅ Пробный период активирован!",
|
||||
"trial_activated_details_message": "🎉 Ваш бесплатный пробный период на <b>{days} дней</b> активирован!\nДействителен до: <b>{end_date}</b>.\nСсылка на конфигурацию: <code>{config_link}</code>\nТрафик: <b>{traffic_gb}</b>\nПриятного использования!",
|
||||
"traffic_unlimited": "Безлимитный",
|
||||
"traffic_na": "н/д",
|
||||
"error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
|
||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
"promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
|
||||
"promo_code_applied_success": "✅ Промокод <code>{code}</code> успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||
|
||||
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
|
||||
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
|
||||
"trial_confirm_prompt": "Хотите активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb}?",
|
||||
"trial_activated_alert": "Пробный период активирован!",
|
||||
"trial_activated_details_message": "🚀 Ваш пробный период на {days} дней активирован!\n\nДействует до: <b>{end_date}</b>\nТрафик: <b>{traffic_gb}</b>\n\nВаша ссылка на конфигурацию:\n<code>{config_link}</code>",
|
||||
"trial_activation_failed": "Не удалось активировать пробный период. Пожалуйста, попробуйте позже.",
|
||||
"trial_activation_failed_panel_link": "Не удалось связать ваш аккаунт с сервером для активации пробного периода. Попробуйте позже.",
|
||||
"trial_activation_failed_db": "Ошибка базы данных при активации пробного периода. Попробуйте позже.",
|
||||
"trial_activation_failed_panel_update": "Не удалось обновить детали на сервере для пробного периода. Попробуйте позже.",
|
||||
"user_not_found_for_trial": "Ваш аккаунт не найден в системе. Пожалуйста, сначала выполните команду /start.",
|
||||
"yes_button": "Да",
|
||||
"no_button": "Нет",
|
||||
"trial_cancelled_short": "Активация отменена.",
|
||||
|
||||
"referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n<code>{referral_link}</code>\n\n<b>Бонусы:</b>{bonus_details}",
|
||||
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
|
||||
"no_bonus_days": "0",
|
||||
"referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.",
|
||||
"referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Ура, {referee_name} (ваш реферал) подписался!\nВам начислено <b>{days} бонусных дней</b>.\nНовая дата окончания: <b>{new_end_date}</b>.",
|
||||
"friend_placeholder": "друг",
|
||||
|
||||
"error_user_not_synced_for_config": "Аккаунт не синхронизирован. Попробуйте /sync или поддержка.",
|
||||
"error_fetching_config_link_details": "Ошибка получения данных для ссылки. Попробуйте или поддержка.",
|
||||
"error_fetching_config_link_details_no_uuid": "ID для ссылки не найден. Поддержка.",
|
||||
"error_generating_config_link": "Не удалось создать ссылку. Попробуйте позже.",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
|
||||
"promo_code_prompt": "Введите ваш промокод:",
|
||||
"promo_code_invalid_or_expired": "❌ Неверный, истекший или уже использованный промокод.",
|
||||
"promo_code_no_active_subscription": "❌ Для применения этого промокода у вас должна быть активная подписка.",
|
||||
"promo_code_applied_success": "✅ Промокод '<code>{code}</code>' применен! <b>{bonus_days} бонусных дней</b> добавлено к вашей подписке.\nТеперь ваша подписка активна до: <b>{new_end_date}</b>.",
|
||||
"promo_code_max_activations_reached": "❌ К сожалению, промокод '<code>{code}</code>' достиг максимального числа активаций.",
|
||||
"promo_code_not_found": "❌ Промокод '<code>{code}</code>' не найден.",
|
||||
"error_applying_promo_bonus": "⚠️ Произошла ошибка при применении промо-бонуса. Попробуйте или поддержка.",
|
||||
"promo_input_cancelled_no_message": "Ввод промокода отменен.",
|
||||
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
||||
|
||||
"user_is_banned": "⚠️ Ваш доступ к боту заблокирован.\nЕсли это ошибка, обратитесь в поддержку.",
|
||||
"contact_support_button": "💬 Поддержка",
|
||||
"suspicious_input_ban_message": "⚠️ Обнаружен подозрительный ввод. В целях безопасности ваш доступ к боту ограничен. Обратитесь в поддержку, если это ошибка.",
|
||||
|
||||
"admin_panel_title": "👑 Админ-панель",
|
||||
"admin_panel_button_reply": "👑 Админ-панель",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_unknown_action": "Неизвестное действие.",
|
||||
"admin_action_cancelled_default": "Действие отменено.",
|
||||
"prev_page_button": "Пред.",
|
||||
"next_page_button": "След.",
|
||||
"yes_button": "✅ Да",
|
||||
"no_button": "❌ Нет",
|
||||
|
||||
"admin_ban_user_button": "🚫 Бан",
|
||||
"admin_unban_user_button": "✅ Разбан",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
"admin_ban_user_prompt": "ID или @username для бана:",
|
||||
"admin_unban_user_prompt": "ID или @username для разбана:",
|
||||
"admin_invalid_user_id_format": "Неверный ID. Нужно число.",
|
||||
"admin_invalid_username_format": "Неверный username. Формат: @юзернейм (5-32 симв.).",
|
||||
"admin_user_not_found_by_username": "Юзер @{username} не найден в боте.",
|
||||
"admin_cannot_ban_self_or_admin": "Нельзя банить себя или админа.",
|
||||
"admin_user_not_found_in_bot_db": "User ID {user_id} не найден.",
|
||||
"admin_user_banned_success_panel_too": "✅ Юзер {user_id_or_username} забанен (БД и панель).",
|
||||
"admin_user_banned_local_panel_fail": "⚠️ Юзер {user_id_or_username} забанен в БД, ошибка панели.",
|
||||
"admin_user_banned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} забанен в БД (нет ID панели).",
|
||||
"admin_user_unbanned_success_panel_too": "✅ Юзер {user_id_or_username} разбанен (БД и панель).",
|
||||
"admin_user_unbanned_local_panel_fail": "⚠️ Юзер {user_id_or_username} разбанен в БД, ошибка панели.",
|
||||
"admin_user_unbanned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} разбанен в БД (нет ID панели).",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже забанен.",
|
||||
"admin_user_not_banned": "Пользователь {user_id_or_username} не забанен.",
|
||||
"admin_confirm_unban_prompt": "Вы уверены, что хотите разбанить пользователя {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_confirm_ban_prompt": "Вы уверены, что хотите забанить пользователя {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_user_unbanned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был разбанен.",
|
||||
"admin_user_banned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был забанен.",
|
||||
"admin_banned_list_title": "🚫 Список забаненных (Стр. {current_page}/{total_pages})",
|
||||
"admin_no_banned_users": "В настоящее время нет забаненных пользователей.",
|
||||
"admin_user_card_title": "👤 Карточка: {user_display}",
|
||||
"user_card_info": "<b>TG ID:</b> <code>{user_id}</code>\n<b>Username:</b> @{username}\n<b>Имя:</b> {first_name} {last_name}\n<b>Язык:</b> {language_code}\n<b>Panel UUID:</b> <code>{panel_user_uuid}</code>\n<b>Статус:</b> {ban_status}\n<b>Регистрация:</b> {reg_date}\n<b>Подписка до:</b> {sub_end_date}",
|
||||
"user_card_banned": "🔴 ЗАБАНЕН",
|
||||
"user_card_active": "🟢 Активен",
|
||||
"user_card_sub_na": "Н/Д",
|
||||
"user_card_unban_button": "✅ Разбанить",
|
||||
"user_card_ban_button": "🚫 Забанить",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку",
|
||||
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_broadcast_enter_message": "Сообщение для рассылки (HTML):",
|
||||
"admin_broadcast_confirm_prompt": "Превью:\n\n---\n{message_preview}\n---\n\nОтправить всем?",
|
||||
"confirm_broadcast_send_button": "✅ Отправить",
|
||||
"cancel_broadcast_button": "❌ Отмена",
|
||||
"admin_broadcast_sending_started": "Рассылка запущена...",
|
||||
"admin_broadcast_error_no_message": "Ошибка: нет сообщения для рассылки.",
|
||||
"admin_broadcast_finished_stats": "📢 Рассылка завершена.\nОтправлено: {sent_count}\nОшибок: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||
|
||||
"admin_create_promo_button": "🎁 Новое промо",
|
||||
"admin_view_promos_button": "📜 Промокоды",
|
||||
"admin_promo_create_prompt": "Промо: <code>КОД ДНИ МАКС [СРОК_ДН]</code>\nНапример: <code>LETO24 7 100 30</code> (действ. 30дн)\nНапример: <code>NEWBIE 5 500</code> (бессроч.)",
|
||||
"admin_promo_invalid_format": "Формат: <code>КОД ДНИ ИСП [СРОК_ДН]</code>",
|
||||
"admin_promo_invalid_values": "Неверные значения. Дни/исп. > 0. Ошибка: {error}",
|
||||
"admin_promo_created_success": "✅ Промо '<code>{code}</code>': {bonus_days}дн, {max_activations} исп, {valid_until_str}.",
|
||||
"admin_promo_valid_until_display": "До: {date}",
|
||||
"admin_promo_valid_indefinitely": "Бессрочно",
|
||||
"admin_promo_creation_failed": "❌ Ошибка создания промо (существует или БД).",
|
||||
"admin_no_active_promos": "Активных промокодов нет.",
|
||||
"admin_active_promos_list_header": "📜 Активные промокоды:",
|
||||
"admin_promo_list_item": "- <code>{code}</code>: {bonus}дн, {current}/{max} исп, До: {valid_until}",
|
||||
|
||||
"admin_panel_title": "Панель администратора",
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_stats_header": "📊 <b>Статистика бота:</b>",
|
||||
"admin_stats_users": "\n👥 <b>Пользователи:</b>\n Всего: {total_users}\n Бан: {banned_users}\n Активные подписки (БД): {active_subs}",
|
||||
"admin_stats_recent_payments_header": "\n💳 <b>Недавние платежи (БД бота):</b>",
|
||||
"admin_stats_payment_item": " {status_emoji} {amount} {currency} от {user_info} ({p_status}) {p_date}",
|
||||
"admin_stats_no_payments_found": "\nПлатежей в БД бота нет.",
|
||||
"admin_stats_last_sync_header": "\n🔄 <b>Синхр. с панелью:</b>",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_create_promo_button": "🎁 Создать промо",
|
||||
"admin_view_promos_button": "👀 Спиок промо",
|
||||
"admin_ban_user_button": "🚫 Забанить",
|
||||
"admin_unban_user_button": "✅ Разбанить",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
"admin_view_logs_menu_button": "📄 Логи",
|
||||
"admin_sync_panel_button": "🔄 Синхронизация",
|
||||
"admin_unknown_action": "Неизвестное действие администратора.",
|
||||
"admin_action_cancelled_default": "Действие отменено. Возврат в меню.",
|
||||
"admin_action_cancelled_default_alert": "Действие отменено",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
"admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}",
|
||||
"admin_stats_recent_payments_header": "Последние платежи:",
|
||||
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
|
||||
"admin_stats_no_payments_found": "Платежей пока нет.",
|
||||
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
|
||||
"admin_stats_sync_time": "Время",
|
||||
"admin_stats_sync_status": "Статус",
|
||||
"admin_stats_sync_users_processed": "Обработано с панели",
|
||||
"admin_stats_sync_subs_synced": "Подписок синхр.",
|
||||
"admin_stats_sync_users_processed": "Обработано юзеров с панели",
|
||||
"admin_stats_sync_subs_synced": "Синхронизировано подписок",
|
||||
"admin_stats_sync_details_label": "Детали",
|
||||
"admin_sync_status_never_run": "Синхронизация не проводилась.",
|
||||
"admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
|
||||
|
||||
"admin_view_logs_menu_button": "📜 Логи",
|
||||
"admin_logs_menu_title": "📜 Логи Активности",
|
||||
"admin_view_all_logs_button": "Все логи",
|
||||
"admin_view_user_logs_prompt_button": "Логи юзера",
|
||||
"admin_prompt_for_user_id_or_username_logs": "ID или @username для логов:",
|
||||
"admin_all_logs_title": "📜 Активность (Стр. {current_page}/{total_pages})",
|
||||
"admin_user_logs_title": "📜 Активность {user_display} (Стр. {current_page}/{total_pages})",
|
||||
"admin_no_logs_found": "Логи не найдены.",
|
||||
"admin_log_entry_format": "[{timestamp_str}] <b>Юзер:</b> {user_display} (ID: {user_id})\n<b>Тип:</b> {event_type}\n<b>Инфо:</b> <code>{content_preview}</code>",
|
||||
"admin_log_user_not_found": "Юзер '{input}' не найден для логов.",
|
||||
"system_or_unknown_user": "Система/Неизв.",
|
||||
|
||||
"admin_sync_panel_button": "🔄 Синхр.",
|
||||
"sync_started": "Синхронизация с панелью начата...",
|
||||
"sync_completed": "Синхронизация с панелью завершена.\nСтатус: {status}\nДетали: {details}",
|
||||
"sync_failed": "Синхронизация с панелью не удалась.\nДетали: {details}",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация с панелью запущена!",
|
||||
"error_cannot_initiate_sync_no_message": "Ошибка: не могу начать синхронизацию без контекста сообщения.",
|
||||
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
|
||||
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?",
|
||||
"confirm_broadcast_send_button": "✅ Отправить",
|
||||
"cancel_broadcast_button": "❌ Отмена",
|
||||
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
||||
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
|
||||
"admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
|
||||
"admin_broadcast_finished_stats": "🏁 Рассылка завершена!\nОтправлено: {sent_count}\nНе удалось отправить: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
||||
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
||||
|
||||
"admin_suspicious_promo_attempt_notification": "⚠️ <b>Подозрительный ввод промокода!</b> ⚠️\n\n<b>Пользователь:</b> {user_first_name} (@{user_username}, ID: <code>{user_id}</code>)\n<b>Ввод:</b> <pre>{promo_code_input}</pre>",
|
||||
"admin_suspicious_promo_attempt_notification_no_username": "⚠️ <b>Подозрительный ввод промокода!</b> ⚠️\n\n<b>Пользователь:</b> {user_first_name} (ID: <code>{user_id}</code>)\n<b>Ввод:</b> <pre>{promo_code_input}</pre>"
|
||||
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
|
||||
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
|
||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_invalid_values": "Неверные значения. {error}",
|
||||
"admin_promo_invalid_format_general": "Ошибка парсинга деталей промокода. Проверьте формат.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод <code>{code}</code> уже существует.",
|
||||
"admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
|
||||
"admin_active_promos_list_header": "Активные промокоды:",
|
||||
"admin_no_active_promos": "Нет активных промокодов.",
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "бессрочно",
|
||||
"admin_promo_valid_until_display": "до {date}",
|
||||
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||
"admin_cannot_ban_self_or_admin": "Вы не можете заблокировать себя или другого администратора.",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
|
||||
"admin_user_banned_success_combined": "✅ Пользователь {user_id_or_username} успешно заблокирован в боте {panel_status_part}.",
|
||||
"admin_panel_ban_success_part": "и на панели",
|
||||
"admin_panel_ban_fail_part": ", но не удалось заблокировать на панели",
|
||||
"admin_user_ban_failed_local_db_error": "❌ Не удалось заблокировать пользователя в локальной БД.",
|
||||
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
|
||||
"admin_user_not_banned": "Пользователь {user_id_or_username} не заблокирован.",
|
||||
"admin_user_unbanned_success_combined": "✅ Пользователь {user_id_or_username} успешно разблокирован в боте {panel_status_part}.",
|
||||
"admin_panel_unban_success_part": "и на панели",
|
||||
"admin_panel_unban_fail_part": ", но не удалось разблокировать на панели",
|
||||
"admin_user_unban_failed_local_db_error": "❌ Не удалось разблокировать пользователя в локальной БД.",
|
||||
"admin_no_banned_users": "В данный момент нет заблокированных пользователей.",
|
||||
"admin_banned_list_title": "Список забаненных (стр. {current_page}/{total_pages}):",
|
||||
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
|
||||
"prev_page_button": "⬅️ Пред.",
|
||||
"next_page_button": "След. ➡️",
|
||||
"admin_user_card_title": "Карточка пользователя: {user_display}",
|
||||
"user_card_info": "ID: <code>{user_id}</code>\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: <code>{panel_user_uuid}</code>\nСтатус: <b>{ban_status}</b>\nРегистрация: {reg_date}\nПодписка до: <b>{sub_end_date}</b>",
|
||||
"user_card_banned": "ЗАБЛОКИРОВАН",
|
||||
"user_card_active": "Активен",
|
||||
"user_card_sub_na": "н/д",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
|
||||
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
||||
"ban_verb_l": "блокировка",
|
||||
"unban_verb_l": "разблокировка",
|
||||
"admin_confirm_ban_prompt": "Вы уверены, что хотите заблокировать пользователя {user_display} (ID: {user_id})?",
|
||||
"admin_confirm_unban_prompt": "Вы уверены, что хотите разблокировать пользователя {user_display} (ID: {user_id})?",
|
||||
"admin_user_banned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) заблокирован.",
|
||||
"admin_user_unbanned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) разблокирован.",
|
||||
"admin_user_ban_failed_db_error": "Ошибка блокировки пользователя в БД.",
|
||||
"admin_user_unban_failed_db_error": "Ошибка разблокировки пользователя в БД.",
|
||||
"admin_panel_status_update_fail_part": ", но статус на панели не обновлен",
|
||||
|
||||
"admin_logs_menu_title": "Меню логов:",
|
||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||
"admin_view_user_logs_prompt_button": "👤 Логи пользователя",
|
||||
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
|
||||
"admin_no_logs_found": "Логи не найдены.",
|
||||
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
|
||||
"system_or_unknown_user": "Система/Неизв.",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
|
||||
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
|
||||
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
|
||||
|
||||
"sync_started": "🔄 Начинаю синхронизацию данных с панелью...",
|
||||
"sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}",
|
||||
"sync_completed": "✅ Синхронизация с панелью завершена. Статус: {status}. Детали: {details}",
|
||||
"sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.",
|
||||
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "нет",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"stub_page_display": "Страница",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
||||
|
||||
"error_unknown": "Произошла неизвестная ошибка."
|
||||
}
|
||||
|
||||
@@ -2,20 +2,24 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.enums import ParseMode
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from bot.main_bot import run_bot
|
||||
from config.settings import get_settings, Settings
|
||||
from db.database import init_db
|
||||
from db.database_setup import init_db, init_db_connection
|
||||
|
||||
|
||||
async def main():
|
||||
load_dotenv()
|
||||
settings = get_settings()
|
||||
|
||||
await init_db()
|
||||
session_factory = init_db_connection(settings)
|
||||
if not session_factory:
|
||||
logging.critical(
|
||||
"Failed to initialize DB connection and session factory. Exiting.")
|
||||
return
|
||||
|
||||
await init_db(settings, session_factory)
|
||||
|
||||
await run_bot(settings)
|
||||
|
||||
@@ -29,3 +33,7 @@ if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logging.info("Bot stopped manually")
|
||||
except Exception as e_global:
|
||||
logging.critical(f"Global unhandled exception in main: {e_global}",
|
||||
exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
aiogram==3.7.0
|
||||
aiogram==3.20.0
|
||||
python-dotenv==1.0.1
|
||||
aiosqlite==0.20.0
|
||||
aiohttp==3.9.5
|
||||
pydantic==2.7.1
|
||||
# Babel==2.15.0 # Using simple JSON i18n for now, uncomment if switching
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
apscheduler==3.10.4
|
||||
pydantic_settings
|
||||
pydantic_settings
|
||||
sqlalchemy[asyncio]==2.0.29
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
Reference in New Issue
Block a user