Migrating to Postgres from sqlite3

This commit is contained in:
machka
2025-05-20 18:41:32 +00:00
parent ac2f83c061
commit a56d804b58
38 changed files with 5017 additions and 4389 deletions
+17 -15
View File
@@ -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", )
+76 -72
View File
@@ -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)
await callback.message.edit_text(
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled"))
await callback.answer(_("admin_broadcast_cancelled"))
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"),
await callback.message.answer(_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang))
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)
+26 -22
View File
@@ -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:
+111 -109
View File
@@ -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)
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)
+109 -75
View File
@@ -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))
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}")
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))
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:
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=code,
bonus_days=bonus_days,
max_activations=max_activations,
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()
+63 -53
View File
@@ -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),
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}",
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
+235 -70
View File
@@ -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")
+346 -377
View File
@@ -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))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
except Exception as e:
logging.warning(f"Edit failed: {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))
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)
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:
await message.answer(_(
"admin_user_banned_local_panel_fail",
user_id_or_username=user_id_or_username_for_msg),
reply_markup=reply_markup_val)
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:
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))
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)
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:
await message.answer(_(
"admin_user_unbanned_local_panel_fail",
user_id_or_username=user_id_or_username_for_msg),
reply_markup=reply_markup_val)
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,88 +340,59 @@ 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:
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_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}",
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")
@@ -390,99 +401,103 @@ async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict,
@router.callback_query(F.data.startswith("admin_ban_confirm:"))
async def confirm_ban_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings):
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)
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_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()
settings: Settings, session: AsyncSession):
await _confirm_action_handler(callback, i18n_data, settings, session,
"ban")
@router.callback_query(F.data.startswith("admin_unban_do:"))
async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings,
@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,
state: FSMContext):
session: AsyncSession,
state: FSMContext, action_type: str):
try:
_, user_id_str, page_str = callback.data.split(":")
user_id_to_unban = 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_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']:
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)
return
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)
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):
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 fail for unban {user_id_to_unban}")
await callback.answer(_("admin_user_unbanned_from_card",
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_to_unban),
user_id=user_id_target) + " " +
panel_action_message,
show_alert=False)
if callback.message:
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,
state,
force_user_id=user_id_to_unban,
session,
force_user_id=user_id_target,
force_page=banned_list_page)
@@ -490,91 +505,45 @@ async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
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,
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'))
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"))
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()
+178 -154
View File
@@ -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,79 +53,109 @@ 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")))
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"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}")
new_sub_details = await subscription_service.activate_subscription(
activation_details = await subscription_service.activate_subscription(
session,
user_id,
subscription_months,
payment_value,
payment_db_id,
db_conn=db,
promo_code_id=promo_code_id)
promo_code_id_from_payment=promo_code_id)
if new_sub_details and new_sub_details.get('end_date'):
base_subscription_end_date = new_sub_details['end_date']
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(
user_id, subscription_months, db_conn=db)
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 = referral_bonus_info.get(
applied_referee_bonus_days_from_referral = 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
)
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 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']
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')
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)
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",
@@ -130,131 +163,115 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
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}"
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:
except Exception as e_notify:
logging.error(
f"Failed to send final payment success message to user {user_id}: {e}"
f"Failed to send final payment success message to user {user_id}: {e_notify}"
)
else:
except Exception as e_process:
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:
logging.error(
f"Error during process_successful_payment transaction for user {user_id}: {e}",
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
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}"
)
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),
@@ -263,49 +280,56 @@ async def yookassa_webhook_route(request: web.Request):
"paid":
bool(payment_data_from_notification.paid),
"amount": {
"value": str(amount_obj.value),
"currency": str(amount_obj.currency)
},
"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)
if payment_data_from_notification.metadata else {},
dict(payment_data_from_notification.metadata),
"description":
str(payment_data_from_notification.description)
if payment_data_from_notification.description else None
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")
async with payment_processing_lock:
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if payment_dict_for_processing.get(
"paid") and payment_dict_for_processing.get(
"status") == "succeeded":
await process_successful_payment(
bot, payment_dict_for_processing, i18n_instance,
settings, panel_service, yk_service,
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')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'"
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(bot,
payment_dict_for_processing,
await process_cancelled_payment(
session, bot, payment_dict_for_processing,
i18n_instance, settings)
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:
await session.commit()
except Exception as e_webhook_db_processing:
await session.rollback()
logging.error(
f"KeyError in yookassa_webhook_route after initial context access: {e}.",
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=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")
status=200, text="ok_internal_processing_error_logged")
return web.Response(status=200, text="ok")
except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json")
except Exception as e_general_webhook:
logging.error(
f"YooKassa Webhook general processing error: {e_general_webhook}",
exc_info=True)
return web.Response(status=200,
text="ok_general_internal_error_logged")
+65 -42
View File
@@ -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"
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,
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",
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(settings.ADMIN_ID,
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: {e_admin_notify}"
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)
+51 -43
View File
@@ -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)
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)
+213 -163
View File
@@ -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,227 +15,271 @@ 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:
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
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_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(
f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}."
"Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
)
if isinstance(
message_or_callback,
types.CallbackQuery) and not answered_callback_internally:
await message_or_callback.answer("Error displaying menu.")
answered_callback_internally = True
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_obj is None for event from user {user_id}."
)
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."
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
)
await message_or_callback.answer()
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:
referral_param = args[1]
if referral_param.startswith("ref_") and referral_param.split(
"_")[1].isdigit():
potential_referrer_id = int(referral_param.split("_")[1])
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 (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:
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:
db_user = await user_dal.create_user(session, user_data_to_create)
logging.info(
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
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.")
else:
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)
else:
logging.info(
f"User {user_id} not eligible for trial button (already had a subscription)."
f"Updated existing user {user_id} in session: {update_payload}"
)
else:
logging.info(f"Trial period is disabled in settings. No trial button.")
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:
if isinstance(event, types.CallbackQuery):
if event.message:
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)
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
user_id = callback.from_user.id
await update_user_language_code(user_id, lang_code)
try:
updated = await user_dal.update_user_language(session, user_id,
lang_code)
if updated:
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,
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,
show_trial_button_flag=show_trial_button_after_lang_change,
subscription_service,
session,
is_edit=True)
@@ -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)
+238 -203
View File
@@ -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,296 +18,333 @@ 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 = get_translation(
"select_subscription_period"
) if settings.subscription_options else get_translation(
text_content = get_text("select_subscription_period"
) if settings.subscription_options else get_text(
"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):
if isinstance(event, types.CallbackQuery):
try:
await target_message.edit_text(text, reply_markup=reply_markup)
await target_message_obj.edit_text(text_content,
reply_markup=reply_markup)
except Exception:
await target_message.answer(text, reply_markup=reply_markup)
await target_message_obj.answer(text_content,
reply_markup=reply_markup)
await event.answer()
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()
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",
confirmation_text_content = get_text("confirm_subscription_prompt",
months=months,
price=price,
price=f"{price_rub:.2f}",
currency_symbol=currency_symbol_val)
reply_markup = get_confirm_subscription_keyboard(months, price,
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,
await callback.message.edit_text(confirmation_text_content,
reply_markup=reply_markup)
except Exception as e:
logging.warning(f"Edit failed: {e}")
await callback.message.answer(confirmation_text,
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",
payment_description = get_text("payment_description_subscription",
months=months)
currency = settings.DEFAULT_CURRENCY_SYMBOL
payment_metadata = {
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),
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["confirmation_url"], current_lang, i18n),
payment_response_yk["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"))
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)
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,
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:
logging.warning(f"Edit 'my_sub' failed: {e}")
await target_message.answer(sub_info_text,
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.answer(sub_info_text,
await target_message_obj.answer(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
@@ -320,8 +354,9 @@ async def my_subscription_command_handler(
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)
+60 -48
View File
@@ -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),
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(
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)
+51 -28
View File
@@ -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(
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)
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=_(key="back_to_admin_panel_button"),
InlineKeyboardButton(
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=_("back_to_admin_panel_button"),
callback_data="admin_action:main"))
builder.adjust(1)
return builder.as_markup()
+209 -109
View File
@@ -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'}"
)
if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
try:
current_webhook_info_before = await bot.get_webhook_info()
current_webhook_info = 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)}"
f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
if full_telegram_webhook_url != "ERROR_URL":
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."
)
else:
logging.error(
"STARTUP: Skipped setting webhook due to URL config error."
"STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
except Exception as e_setwebhook:
logging.error(
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
exc_info=True)
else:
logging.error(
"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:
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}.")
logging.info(
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(
settings_param.yookassa_webhook_path,
yk_path,
user_payment_webhook_module.yookassa_webhook_route)
logging.info(
f"YK webhook route: {settings_param.yookassa_webhook_path}")
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()
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()}' cancelled.")
except Exception as e_cancel:
logging.info(
f"Task '{task.get_name()}' was cancelled successfully."
)
except Exception as e_task_cancel:
logging.error(
f"Error cancelling task '{task.get_name()}': {e_cancel}",
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()
logging.info("Dispatcher shutdown sequence emitted.")
logging.info("Bot run_bot function finished.")
+50 -39
View File
@@ -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
+50 -152
View File
@@ -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
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}"
)
i18n_to_use: Optional[JsonI18n] = i18n_data_from_event.get(
"i18n_instance", self.i18n_main_instance)
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)}"
)
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)
logging.debug(
"BanCheck: [D] Attempting to get 'user_is_banned' text."
)
_ = lambda k, **kw: i18n_to_use.gettext(current_lang, k, **kw)
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."
)
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()
logging.debug(
f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}"
)
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
try:
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,
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(
except (TelegramAPIError, AiogramError):
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(
actual_event_object.from_user.id,
ban_message_text,
reply_markup=keyboard)
else:
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)
+61 -64
View File
@@ -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]
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
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}"
)
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 language from DB for user {event_user.id}: {e_db_lang}. Falling back."
)
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]
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()
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)
+77 -56
View File
@@ -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)
if async_session_factory is None:
logging.error(
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
)
return
expiring_subs = await sub_service.get_subscriptions_ending_soon(
settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
async with async_session_factory() as session:
try:
if not expiring_subs:
logging.info("No subscriptions found ending soon for notification.")
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"Found {len(expiring_subs)} subscriptions ending soon for notification."
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
)
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}"
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
)
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,
end_date=end_date_str_for_msg,
days_left=days_left_display)
try:
await bot.send_message(user_id, message_text)
await sub_service.update_last_notification_sent(
user_id, end_date_str)
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
)
except Exception as e:
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 to user {user_id}: {e}")
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.")
async def schedule_subscription_notifications(bot: Bot, settings: Settings,
i18n: JsonI18n,
scheduler: AsyncIOScheduler):
"""Schedules the daily job to send expiration warnings."""
except Exception as e_session:
logging.error(
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, 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."
)
+303 -122
View File
@@ -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,152 +24,303 @@ 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:
async with session.request(method, url, headers=headers,
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 aiohttp_session.request(method.upper(),
url_for_request,
headers=headers,
**kwargs) as response:
if 200 <= response.status < 300:
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": await response.text()
"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_parse_error",
"code": response_status,
"data_text": response_text,
"parse_error": str(e_json_ok)
}
else:
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")
error_details = {
"message":
f"Request failed with status {response_status}",
"raw_response_text": response_text
}
except aiohttp.ContentTypeError:
error_text = await response.text()
logging.error(
f"{log_prefix} - Failed ({response.status}): {error_text}"
)
try:
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,
"message": error_text
"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_users_by_filter(
async def get_all_panel_users(
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.")
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
params = {"username": username}
response_data = await self._request("GET", "/users", params=params)
async def get_users_by_filter(
self,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
log_response: bool = True) -> Optional[List[Dict[str, Any]]]:
if response_data and not response_data.get("error"):
users_list = response_data.get("response", {}).get("users", [])
response_data = None
filter_used_log = "No filter specified"
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"Found {len(users_list)} panel users matching filter: {params}"
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,
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
) -> Optional[Dict[str, Any]]:
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, _, -)."
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,
"response": {
"message": msg,
"errorCode": "VALIDATION_ERROR"
}
"errorCode": "VALIDATION_ERROR_USERNAME"
}
now = datetime.now(timezone.utc)
@@ -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
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"):
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 -> {'enabled' if enable else 'disabled'}."
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
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)
+135 -75
View File
@@ -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
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logging.info(f"YooKassa configured for shop_id: {shop_id}")
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_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
receipt_payload: Dict[str, Any] = {
"customer": receipt_customer,
"items": receipt_items
}
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
}
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
+36 -47
View File
@@ -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",
return False, _("promo_code_not_found", 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)
if promo_data['current_activations'] >= promo_data[
'max_activations']:
return False, _("promo_code_max_activations_reached",
code=code_input_upper)
active_sub = await self.subscription_service.get_active_subscription(
user_id)
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(
session=session,
user_id=user_id,
bonus_days=bonus_days,
db_conn=db,
reason=f"promo code {code_input_upper}")
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",
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)
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:
await db.rollback()
return False, _("promo_code_invalid_or_expired")
else:
await db.rollback()
return False, _("error_applying_promo_bonus")
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")
+159 -66
View File
@@ -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
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(
if inviter_bonus_days and inviter_bonus_days > 0:
if not inviter_user_model:
logging.warning(
f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter 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,
db_conn=db_conn)
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 applied for inviter {inviter_user_id}.")
f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}."
)
try:
inviter_lang = inviter_user_row.get(
'language_code', default_lang_for_placeholder)
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,
referee_name=referee_name,
new_end_date=new_end_date_inviter.strftime(
'%Y-%m-%d')))
except Exception as e:
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}"
f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
)
else:
logging.warning(
f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
logging.info(
f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
)
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:
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"Bonus applied for referee {referee_user_id}.")
referee_final_end_date = new_end_date_referee
referee_bonus_applied_days = referee_bonus
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 apply bonus subscription extension for referee {referee_user_id}."
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 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
+101 -76
View File
@@ -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 = [
return [
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
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
+76
View File
@@ -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
+50
View File
@@ -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
+115
View File
@@ -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()
+126
View File
@@ -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
+205
View File
@@ -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()
+124
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -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
View File
@@ -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'), )
+168 -151
View File
@@ -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": "⬅️ Назад",
"no_subscription_options_available": "Варианты подписки сейчас недоступны.",
"select_subscription_period": "Выберите период подписки:",
"choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!",
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
"error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.",
"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": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.",
"traffic_unlimited": "Безлимитный",
"traffic_na": "н/д",
"error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.",
"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Приятного использования!",
"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": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
"error_user_not_synced_for_config": "Аккаунт не синхронизирован. Попробуйте /sync или поддержка.",
"error_fetching_config_link_details": "Ошибка получения данных для ссылки. Попробуйте или поддержка.",
"error_fetching_config_link_details_no_uuid": "ID для ссылки не найден. Поддержка.",
"error_generating_config_link": "Не удалось создать ссылку. Попробуйте позже.",
"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": "⚠️ Ваш доступ к боту заблокирован.\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}",
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
"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_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_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": "[{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}' не найден для логов.",
"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}):",
"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": "Ошибка: не могу начать синхронизацию без контекста сообщения.",
"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": "Страница",
"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>"
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
"error_unknown": "Произошла неизвестная ошибка."
}
+12 -4
View File
@@ -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)
+4 -3
View File
@@ -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
sqlalchemy[asyncio]==2.0.29
asyncpg==0.29.0
alembic==1.13.1