diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py
index 3ec5047..ddfe2bb 100644
--- a/bot/handlers/admin/__init__.py
+++ b/bot/handlers/admin/__init__.py
@@ -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", )
diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py
index 5668c4b..bf67826 100644
--- a/bot/handlers/admin/broadcast.py
+++ b/bot/handlers/admin/broadcast.py
@@ -1,15 +1,17 @@
import logging
import asyncio
from aiogram import Router, F, types, Bot
-from aiogram.filters import Command
+
from aiogram.fsm.context import FSMContext
from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
-from db.database import get_all_users_for_broadcast, log_user_action
+from db.dal import user_dal, message_log_dal
+
from bot.states.admin_states import AdminStates
-from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard
+from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_broadcast_router")
@@ -17,9 +19,9 @@ router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in broadcast_message_prompt_handler")
@@ -37,7 +39,8 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
current_lang, i18n))
except Exception as e:
logging.warning(
- f"Could not edit message for broadcast prompt: {e}")
+ f"Could not edit message for broadcast prompt: {e}. Sending new."
+ )
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(
@@ -49,9 +52,9 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
async def process_broadcast_message_handler(message: types.Message,
state: FSMContext, i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in process_broadcast_message_handler")
@@ -63,8 +66,9 @@ async def process_broadcast_message_handler(message: types.Message,
broadcast_message_text = message.html_text
await state.update_data(broadcast_message=broadcast_message_text)
- preview_snippet = broadcast_message_text[:200] + "..." if len(
- broadcast_message_text) > 200 else broadcast_message_text
+ preview_snippet = (
+ broadcast_message_text[:200] +
+ "...") if len(broadcast_message_text) > 200 else broadcast_message_text
confirmation_prompt = _("admin_broadcast_confirm_prompt",
message_preview=preview_snippet)
@@ -79,34 +83,27 @@ async def process_broadcast_message_handler(message: types.Message,
AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
state: FSMContext,
- settings: Settings,
- i18n_data: dict):
- from .common import admin_panel_actions_callback_handler
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings, i18n_data: dict,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing")
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- if callback.message:
- try:
- await callback.message.edit_text(_("admin_broadcast_cancelled"),
- reply_markup=None)
- except Exception:
- await callback.message.answer(_("admin_broadcast_cancelled"))
- await callback.answer(_("admin_broadcast_cancelled"))
+ try:
+ await callback.message.edit_text(
+ _("admin_broadcast_cancelled_nav_back"), reply_markup=None)
+ except Exception:
+ await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
+
+ await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear()
- callback.data = "admin_action:main"
-
- from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
- if callback.message:
- await callback.message.answer(_("admin_panel_title"),
- reply_markup=get_admin_panel_keyboard(
- i18n, current_lang))
+ await callback.message.answer(_(key="admin_panel_title"),
+ reply_markup=get_admin_panel_keyboard(
+ i18n, current_lang, settings))
@router.callback_query(F.data.startswith("broadcast_final_action:"),
@@ -114,80 +111,87 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict, bot: Bot,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing")
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error processing broadcast confirmation.",
+ show_alert=True)
return
-
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1]
user_fsm_data = await state.get_data()
- broadcast_message = user_fsm_data.get("broadcast_message")
-
- if not callback.message:
- await callback.answer("Error: message context lost.", show_alert=True)
- await state.clear()
- return
+ broadcast_message_to_send = user_fsm_data.get("broadcast_message")
if action == "send":
- if not broadcast_message:
+ if not broadcast_message_to_send:
await callback.message.edit_text(
_("admin_broadcast_error_no_message"))
await state.clear()
- await callback.answer(show_alert=True)
+ await callback.answer(_("admin_broadcast_error_no_message_alert"),
+ show_alert=True)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"),
reply_markup=None)
await callback.answer()
- users_to_broadcast = await get_all_users_for_broadcast()
+ user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
+ session)
+
sent_count = 0
failed_count = 0
logging.info(
- f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users."
+ f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
)
-
admin_user = callback.from_user
- for user_row in users_to_broadcast:
- user_id = user_row['user_id']
+ for user_id_to_send in user_ids_for_broadcast:
try:
- await bot.send_message(user_id,
- broadcast_message,
+ await bot.send_message(user_id_to_send,
+ broadcast_message_to_send,
parse_mode="HTML")
sent_count += 1
- await log_user_action(
- user_id=admin_user.id,
- telegram_username=admin_user.username,
- telegram_first_name=admin_user.first_name,
- event_type="admin_broadcast_sent",
- content=f"To user {user_id}: {broadcast_message[:70]}...",
- is_admin_event=True,
- target_user_id=user_id)
+ log_payload_sent = {
+ "user_id": admin_user.id,
+ "telegram_username": admin_user.username,
+ "telegram_first_name": admin_user.first_name,
+ "event_type": "admin_broadcast_sent",
+ "content":
+ f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
+ "is_admin_event": True,
+ "target_user_id": user_id_to_send
+ }
+ await message_log_dal.create_message_log(
+ session, log_payload_sent)
except Exception as e:
failed_count += 1
logging.warning(
- f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}"
+ f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
)
-
- await log_user_action(
- user_id=admin_user.id,
- telegram_username=admin_user.username,
- telegram_first_name=admin_user.first_name,
- event_type="admin_broadcast_failed",
- content=
- f"For user {user_id}: {type(e).__name__} - {str(e)[:70]}...",
- is_admin_event=True,
- target_user_id=user_id)
+ log_payload_failed = {
+ "user_id": admin_user.id,
+ "telegram_username": admin_user.username,
+ "telegram_first_name": admin_user.first_name,
+ "event_type": "admin_broadcast_failed",
+ "content":
+ f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
+ "is_admin_event": True,
+ "target_user_id": user_id_to_send
+ }
+ await message_log_dal.create_message_log(
+ session, log_payload_failed)
await asyncio.sleep(0.05)
+ try:
+ await session.commit()
+ except Exception as e_commit:
+ await session.rollback()
+ logging.error(f"Error committing broadcast logs: {e_commit}")
+
result_message = _("admin_broadcast_finished_stats",
sent_count=sent_count,
failed_count=failed_count)
diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py
index ecba824..9e92f11 100644
--- a/bot/handlers/admin/common.py
+++ b/bot/handlers/admin/common.py
@@ -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:
diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py
index 4f7d726..5f43c08 100644
--- a/bot/handlers/admin/logs_admin.py
+++ b/bot/handlers/admin/logs_admin.py
@@ -4,18 +4,18 @@ import re
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional, List, Dict, Any
-import aiosqlite
+
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
-from db.database import (get_all_message_logs_paginated,
- count_all_message_logs,
- get_user_message_logs_paginated,
- count_user_message_logs, get_user,
- get_user_by_telegram_username)
+
+from db.dal import message_log_dal, user_dal
+from db.models import MessageLog, User
+
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import (
get_logs_menu_keyboard, get_logs_pagination_keyboard,
- get_back_to_admin_panel_keyboard, get_admin_panel_keyboard)
+ get_back_to_admin_panel_keyboard)
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_logs_router")
@@ -23,19 +23,13 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
- settings: Settings):
+ settings: Settings, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n_instance missing in display_logs_menu")
- await callback.answer("Language service error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error displaying logs menu.", show_alert=True)
return
- if not callback.message:
- logging.error("CallbackQuery has no message in display_logs_menu")
- await callback.answer("Error processing request.", show_alert=True)
- return
-
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
@@ -43,7 +37,8 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
reply_markup=get_logs_menu_keyboard(
i18n, current_lang))
except Exception as e:
- logging.warning(f"Failed to edit message for logs menu: {e}")
+ logging.warning(
+ f"Failed to edit message for logs menu: {e}. Sending new.")
await callback.message.answer(text=_(key="admin_logs_menu_title"),
reply_markup=get_logs_menu_keyboard(
i18n, current_lang))
@@ -51,9 +46,9 @@ async def display_logs_menu(callback: types.CallbackQuery, i18n_data: dict,
async def _display_formatted_logs(target_message: types.Message,
- logs: List[aiosqlite.Row],
+ logs: List[MessageLog],
total_logs: int,
- current_page: int,
+ current_page_idx: int,
settings: Settings,
title_key: str,
base_pagination_callback_data: str,
@@ -63,7 +58,6 @@ async def _display_formatted_logs(target_message: types.Message,
Any]] = None):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = settings.LOGS_PAGE_SIZE
-
actual_title_kwargs = title_kwargs or {}
if not logs and total_logs == 0:
@@ -71,7 +65,7 @@ async def _display_formatted_logs(target_message: types.Message,
title_key, current_page=1, total_pages=1, **
actual_title_kwargs) + "\n\n" + _("admin_no_logs_found")
reply_markup = get_logs_pagination_keyboard(
- current_page,
+ current_page_idx,
1,
base_pagination_callback_data,
i18n,
@@ -80,55 +74,46 @@ async def _display_formatted_logs(target_message: types.Message,
else:
total_pages = math.ceil(total_logs / page_size) if page_size > 0 else 1
text = _(title_key,
- current_page=current_page + 1,
+ current_page=current_page_idx + 1,
total_pages=max(1, total_pages),
**actual_title_kwargs) + "\n"
log_entries_text = []
- for log_entry in logs:
+ for log_entry_model in logs:
user_display_parts = []
-
- telegram_first_name = log_entry[
- 'telegram_first_name'] if 'telegram_first_name' in log_entry.keys(
- ) and log_entry['telegram_first_name'] else None
- telegram_username = log_entry[
- 'telegram_username'] if 'telegram_username' in log_entry.keys(
- ) and log_entry['telegram_username'] else None
- user_id_from_log = log_entry[
- 'user_id'] if 'user_id' in log_entry.keys(
- ) and log_entry['user_id'] else None
-
- if telegram_first_name:
- user_display_parts.append(telegram_first_name)
- if telegram_username:
- user_display_parts.append(f"(@{telegram_username})")
+ if log_entry_model.telegram_first_name:
+ user_display_parts.append(log_entry_model.telegram_first_name)
+ if log_entry_model.telegram_username:
+ user_display_parts.append(
+ f"(@{log_entry_model.telegram_username})")
user_display = " ".join(user_display_parts).strip()
if not user_display:
user_display = _(
"system_or_unknown_user"
- ) if not user_id_from_log else f"ID: {user_id_from_log}"
+ ) if not log_entry_model.user_id else f"ID: {log_entry_model.user_id}"
user_id_display = str(
- user_id_from_log) if user_id_from_log is not None else "N/A"
- content_raw = log_entry['content'] if 'content' in log_entry.keys(
- ) and log_entry['content'] else ""
+ log_entry_model.user_id
+ ) if log_entry_model.user_id is not None else "N/A"
+ content_raw = log_entry_model.content or ""
content_preview = (content_raw[:100] +
"...") if len(content_raw) > 100 else (
content_raw or "N/A")
+ timestamp_str_display = log_entry_model.timestamp.strftime(
+ '%Y-%m-%d %H:%M:%S') if log_entry_model.timestamp else 'N/A'
+
log_entries_text.append(
_("admin_log_entry_format",
- timestamp_str=log_entry['timestamp_str']
- if 'timestamp_str' in log_entry.keys() else 'N/A',
+ timestamp_str=timestamp_str_display,
user_display=user_display,
user_id=user_id_display,
- event_type=log_entry['event_type']
- if 'event_type' in log_entry.keys() else 'N/A',
+ event_type=log_entry_model.event_type or 'N/A',
content_preview=content_preview).replace("\n", "\n "))
text += "\n\n".join(log_entries_text)
reply_markup = get_logs_pagination_keyboard(
- current_page,
+ current_page_idx,
total_pages,
base_pagination_callback_data,
i18n,
@@ -142,30 +127,40 @@ async def _display_formatted_logs(target_message: types.Message,
disable_web_page_preview=True)
except Exception as e:
logging.warning(
- f"Failed to edit message for logs display: {e}. Content length: {len(text)}"
+ f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)."
)
- chunk_size = 4000
- for i in range(0, len(text), chunk_size):
- chunk = text[i:i + chunk_size]
- is_last_chunk = (i + chunk_size) >= len(text)
- await target_message.answer(
- chunk,
- reply_markup=reply_markup if is_last_chunk else None,
- parse_mode="HTML",
- disable_web_page_preview=True)
+
+ max_chunk_size = 4000
+ for i in range(0, len(text), max_chunk_size):
+ chunk = text[i:i + max_chunk_size]
+ is_last_chunk = (i + max_chunk_size) >= len(text)
+ try:
+ await target_message.answer(
+ chunk,
+ reply_markup=reply_markup if is_last_chunk else None,
+ parse_mode="HTML",
+ disable_web_page_preview=True)
+ except Exception as e_chunk:
+ logging.error(f"Failed to send log chunk: {e_chunk}")
+
+ if i == 0:
+ await target_message.answer(
+ _("error_displaying_logs_too_long"),
+ reply_markup=reply_markup if is_last_chunk else None)
+ break
@router.callback_query(F.data.startswith("admin_logs:view_all"))
async def view_all_logs_handler(callback: types.CallbackQuery,
- settings: Settings, i18n_data: dict):
- page = 0
+ settings: Settings, i18n_data: dict,
+ session: AsyncSession):
+ page_idx = 0
parts = callback.data.split(":")
-
if len(parts) == 3:
try:
- page = int(parts[2])
+ page_idx = int(parts[2])
except ValueError:
- page = 0
+ page_idx = 0
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -173,15 +168,15 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
await callback.answer("Error processing request.", show_alert=True)
return
- logs, total_logs = await get_all_message_logs_paginated(
- settings.LOGS_PAGE_SIZE,
- page * settings.LOGS_PAGE_SIZE), await count_all_message_logs()
+ logs_models = await message_log_dal.get_all_message_logs(
+ session, settings.LOGS_PAGE_SIZE, page_idx * settings.LOGS_PAGE_SIZE)
+ total_logs_count = await message_log_dal.count_all_message_logs(session)
await _display_formatted_logs(
target_message=callback.message,
- logs=logs,
- total_logs=total_logs,
- current_page=page,
+ logs=logs_models,
+ total_logs=total_logs_count,
+ current_page_idx=page_idx,
settings=settings,
title_key="admin_all_logs_title",
base_pagination_callback_data="admin_logs:view_all",
@@ -193,11 +188,13 @@ async def view_all_logs_handler(callback: types.CallbackQuery,
@router.callback_query(F.data == "admin_logs:prompt_user")
async def prompt_user_for_logs_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
+ settings: Settings,
+ session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
- await callback.answer("Error")
+ await callback.answer("Error preparing user log prompt.",
+ show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
@@ -211,103 +208,106 @@ async def prompt_user_for_logs_handler(callback: types.CallbackQuery,
@router.message(AdminStates.waiting_for_user_id_for_logs, F.text)
async def process_user_id_for_logs_handler(message: types.Message,
state: FSMContext,
- settings: Settings,
- i18n_data: dict):
- current_state_fsm = await state.get_state()
- logging.info(
- f"Processing user input for logs in state {current_state_fsm}: '{message.text}'"
- )
+ settings: Settings, i18n_data: dict,
+ session: AsyncSession):
await state.clear()
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n:
- await message.reply("Language error.")
+ await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- input_text = message.text.strip()
- user_data_for_logs: Optional[aiosqlite.Row] = None
+ input_text = message.text.strip() if message.text else ""
+ user_model_for_logs: Optional[User] = None
if input_text.isdigit():
try:
- user_data_for_logs = await get_user(int(input_text))
+ user_model_for_logs = await user_dal.get_user_by_id(
+ session, int(input_text))
except ValueError:
pass
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
- user_data_for_logs = await get_user_by_telegram_username(input_text[1:]
- )
+ user_model_for_logs = await user_dal.get_user_by_username(
+ session, input_text[1:])
elif USERNAME_REGEX.match(input_text):
- user_data_for_logs = await get_user_by_telegram_username(input_text)
+ user_model_for_logs = await user_dal.get_user_by_username(
+ session, input_text)
- if not user_data_for_logs:
+ if not user_model_for_logs:
await message.answer(_("admin_log_user_not_found", input=input_text))
return
- target_user_id = user_data_for_logs['user_id']
- user_display = user_data_for_logs['first_name'] or (
- f"@{user_data_for_logs['username']}"
- if user_data_for_logs.get('username') else f"ID {target_user_id}")
+ target_user_id = user_model_for_logs.user_id
+ user_display_name = user_model_for_logs.first_name or (
+ f"@{user_model_for_logs.username}"
+ if user_model_for_logs.username else f"ID {target_user_id}")
- logs, total_logs = await get_user_message_logs_paginated(
- target_user_id, settings.LOGS_PAGE_SIZE,
- 0), await count_user_message_logs(target_user_id)
+ logs_models = await message_log_dal.get_user_message_logs(
+ session, target_user_id, settings.LOGS_PAGE_SIZE, 0)
+ total_user_logs_count = await message_log_dal.count_user_message_logs(
+ session, target_user_id)
await _display_formatted_logs(
target_message=message,
- logs=logs,
- total_logs=total_logs,
- current_page=0,
+ logs=logs_models,
+ total_logs=total_user_logs_count,
+ current_page_idx=0,
settings=settings,
title_key="admin_user_logs_title",
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
- title_kwargs={"user_display": user_display})
+ title_kwargs={"user_display": user_display_name})
@router.callback_query(F.data.startswith("admin_logs:view_user:"))
async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
- settings: Settings,
- i18n_data: dict):
+ settings: Settings, i18n_data: dict,
+ session: AsyncSession):
try:
parts = callback.data.split(":")
target_user_id = int(parts[2])
- page = int(parts[3])
+ page_idx = int(parts[3])
except (IndexError, ValueError):
- await callback.answer("Invalid log request.", show_alert=True)
+ await callback.answer("Invalid log request format.", show_alert=True)
return
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
- await callback.answer("Error")
+ await callback.answer("Error processing request.", show_alert=True)
return
- user_data_for_logs = await get_user(target_user_id)
- if not user_data_for_logs:
+ user_model_for_logs = await user_dal.get_user_by_id(
+ session, target_user_id)
+ if not user_model_for_logs:
await callback.message.edit_text("User not found for logs.")
await callback.answer()
return
- user_display = user_data_for_logs['first_name'] or (
- f"@{user_data_for_logs['username']}"
- if user_data_for_logs.get('username') else f"ID {target_user_id}")
- logs, total_logs = await get_user_message_logs_paginated(
- target_user_id, settings.LOGS_PAGE_SIZE, page *
- settings.LOGS_PAGE_SIZE), await count_user_message_logs(target_user_id)
+ user_display_name = user_model_for_logs.first_name or (
+ f"@{user_model_for_logs.username}"
+ if user_model_for_logs.username else f"ID {target_user_id}")
+
+ logs_models = await message_log_dal.get_user_message_logs(
+ session, target_user_id, settings.LOGS_PAGE_SIZE,
+ page_idx * settings.LOGS_PAGE_SIZE)
+ total_user_logs_count = await message_log_dal.count_user_message_logs(
+ session, target_user_id)
await _display_formatted_logs(
target_message=callback.message,
- logs=logs,
- total_logs=total_logs,
- current_page=page,
+ logs=logs_models,
+ total_logs=total_user_logs_count,
+ current_page_idx=page_idx,
settings=settings,
title_key="admin_user_logs_title",
base_pagination_callback_data=f"admin_logs:view_user:{target_user_id}",
i18n=i18n,
current_lang=current_lang,
- title_kwargs={"user_display": user_display})
+ title_kwargs={"user_display": user_display_name})
await callback.answer()
@@ -316,6 +316,8 @@ async def view_user_logs_paginated_handler(callback: types.CallbackQuery,
async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
- i18n_data: dict):
+ i18n_data: dict,
+ session: AsyncSession):
await state.clear()
- await display_logs_menu(callback, i18n_data, settings)
+
+ await display_logs_menu(callback, i18n_data, settings, session)
diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py
index 7655572..96042ff 100644
--- a/bot/handlers/admin/promo_codes.py
+++ b/bot/handlers/admin/promo_codes.py
@@ -1,14 +1,16 @@
import logging
from aiogram import Router, F, types, Bot
-from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
-from db.database import create_promo_code_db, get_promo_codes_db
+
+from db.dal import promo_code_dal
+
from bot.states.admin_states import AdminStates
-from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
+from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_promo_codes_router")
@@ -16,32 +18,29 @@ router = Router(name="admin_promo_codes_router")
async def create_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing in create_promo_prompt_handler")
- await callback.answer("Language service error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error preparing promo creation.",
+ show_alert=True)
return
-
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _("admin_promo_create_prompt",
example_format="MYPROMO20 7 100 30")
- if callback.message:
- try:
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
- except Exception as e:
- logging.warning(f"Could not edit message for promo prompt: {e}")
- await callback.message.answer(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
+ try:
+ await callback.message.edit_text(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ except Exception as e:
+ logging.warning(
+ f"Could not edit message for promo prompt: {e}. Sending new.")
+ await callback.message.answer(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_details)
@@ -50,87 +49,112 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
async def process_promo_code_details_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
- logging.error("i18n missing in process_promo_code_details_handler")
await message.reply("Language service error.")
return
-
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+ if not message.text:
+ await message.answer(_("admin_promo_invalid_format"))
+ return
+
parts = message.text.strip().split()
if not (3 <= len(parts) <= 4):
await message.answer(_("admin_promo_invalid_format"))
return
try:
- code = parts[0].upper()
- if not (3 <= len(code) <= 30 and code.isalnum()):
- raise ValueError(
- "Promo code must be 3-30 alphanumeric characters.")
+ code_str = parts[0].upper()
+ if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
+ raise ValueError(_("admin_promo_invalid_code_format"))
+
bonus_days = int(parts[1])
max_activations = int(parts[2])
+
valid_until_date: Optional[datetime] = None
valid_until_str_display = _("admin_promo_valid_indefinitely")
if len(parts) == 4:
valid_days_from_now = int(parts[3])
if valid_days_from_now <= 0:
- raise ValueError(
- "Validity days (if provided) must be positive.")
-
+ raise ValueError(_("admin_promo_invalid_validity_days"))
valid_until_date = datetime.now(
timezone.utc) + timedelta(days=valid_days_from_now)
valid_until_str_display = _(
"admin_promo_valid_until_display",
date=valid_until_date.strftime('%Y-%m-%d'))
+
if bonus_days <= 0 or max_activations <= 0:
- raise ValueError(
- "Bonus days and max activations must be positive.")
+ raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
+
except ValueError as e:
await message.answer(_("admin_promo_invalid_values", error=str(e)))
return
+ except Exception as e_parse:
+ logging.error(
+ f"Error parsing promo details '{message.text}': {e_parse}")
+ await message.answer(_("admin_promo_invalid_format_general"))
+ return
- admin_id = message.from_user.id
+ admin_id = message.from_user.id if message.from_user else 0
- promo_id = await create_promo_code_db(code, bonus_days, max_activations,
- admin_id, valid_until_date)
+ promo_data_to_create = {
+ "code": code_str,
+ "bonus_days": bonus_days,
+ "max_activations": max_activations,
+ "created_by_admin_id": admin_id,
+ "valid_until": valid_until_date,
+ "is_active": True,
+ "current_activations": 0
+ }
- if promo_id:
- success_text = _("admin_promo_created_success",
- code=code,
- bonus_days=bonus_days,
- max_activations=max_activations,
- valid_until_str=valid_until_str_display)
- await message.answer(success_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
- else:
+ try:
+ created_promo = await promo_code_dal.create_promo_code(
+ session, promo_data_to_create)
+ await session.commit()
+
+ if created_promo:
+ success_text = _("admin_promo_created_success",
+ code=created_promo.code,
+ bonus_days=created_promo.bonus_days,
+ max_activations=created_promo.max_activations,
+ valid_until_str=valid_until_str_display)
+ await message.answer(success_text,
+ reply_markup=get_back_to_admin_panel_keyboard(
+ current_lang, i18n))
+ else:
+ await message.answer(
+ _("admin_promo_creation_failed_duplicate", code=code_str))
+
+ except Exception as e_db_create:
+ await session.rollback()
+ logging.error(
+ f"Failed to create promo code '{code_str}' in DB: {e_db_create}",
+ exc_info=True)
await message.answer(_("admin_promo_creation_failed"))
+
await state.clear()
async def view_promo_codes_handler(callback: types.CallbackQuery,
- i18n_data: dict, settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ i18n_data: dict, settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing in view_promo_codes_handler")
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error displaying promo codes.", show_alert=True)
return
-
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- promos = await get_promo_codes_db(is_active_only=True, limit=20)
- if not callback.message:
- await callback.answer("Error: message context lost.", show_alert=True)
- return
+ promo_models = await promo_code_dal.get_all_active_promo_codes(session,
+ limit=20,
+ offset=0)
- if not promos:
+ if not promo_models:
await callback.message.edit_text(
_("admin_no_active_promos"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
@@ -138,36 +162,18 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
return
response_text_parts = [f"{_('admin_active_promos_list_header')}\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()
diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py
index 439276b..616ea09 100644
--- a/bot/handlers/admin/statistics.py
+++ b/bot/handlers/admin/statistics.py
@@ -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"{_('admin_stats_header')}"]
- 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{_('admin_stats_recent_payments_header')}")
- 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{_('admin_stats_last_sync_header')}")
- sync_time_val = sync_status['last_sync_time']
- sync_time_str = "N/A"
- if isinstance(sync_time_val, datetime):
- sync_time_str = sync_time_val.strftime('%Y-%m-%d %H:%M:%S UTC')
- elif isinstance(sync_time_val, str):
- sync_time_str = sync_time_val[:19] if len(
- sync_time_val) > 19 else sync_time_val
+ sync_time_val = sync_status_model.last_sync_time
+ sync_time_str = sync_time_val.strftime(
+ '%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
- details_val = sync_status['details']
+ details_val = sync_status_model.details
details_str = (details_val[:100] +
"...") if details_val and len(details_val) > 100 else (
details_val or "N/A")
@@ -80,12 +86,12 @@ async def show_statistics_handler(callback: types.CallbackQuery,
stats_text_parts.append(
f" {_('admin_stats_sync_time')}: {sync_time_str}")
stats_text_parts.append(
- f" {_('admin_stats_sync_status')}: {sync_status['status']}")
+ f" {_('admin_stats_sync_status')}: {sync_status_model.status}")
stats_text_parts.append(
- f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}"
+ f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}"
)
stats_text_parts.append(
- f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}"
+ f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}"
)
stats_text_parts.append(
f" {_('admin_stats_sync_details_label')}: {details_str}")
@@ -94,26 +100,30 @@ async def show_statistics_handler(callback: types.CallbackQuery,
final_text = "\n".join(stats_text_parts)
- if callback.message:
- try:
- await callback.message.edit_text(
- final_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n),
- parse_mode="HTML")
- except Exception as e:
- logging.error(f"Error editing message for statistics: {e}",
- exc_info=True)
+ try:
+ await callback.message.edit_text(
+ final_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
+ parse_mode="HTML")
+ except Exception as e_edit:
+ logging.error(f"Error editing message for statistics: {e_edit}",
+ exc_info=True)
- for chunk in [
- final_text[i:i + 4000]
- for i in range(0, len(final_text), 4000)
- ]:
+ max_chunk_size = 4000
+ for i in range(0, len(final_text), max_chunk_size):
+ chunk = final_text[i:i + max_chunk_size]
+ is_last_chunk = (i + max_chunk_size) >= len(final_text)
+ try:
await callback.message.answer(
chunk,
reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n)
- if chunk == final_text[-len(chunk):] else None,
+ current_lang, i18n) if is_last_chunk else None,
parse_mode="HTML")
- else:
- logging.error("Cannot send statistics, callback.message is None.")
+ except Exception as e_chunk:
+ logging.error(f"Failed to send statistics chunk: {e_chunk}")
+ if i == 0:
+ await callback.message.answer(
+ _("error_displaying_statistics"),
+ reply_markup=get_back_to_admin_panel_keyboard(
+ current_lang, i18n))
+ break
diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py
index 8e60ecf..ed26d0e 100644
--- a/bot/handlers/admin/sync_admin.py
+++ b/bot/handlers/admin/sync_admin.py
@@ -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"{_('admin_stats_last_sync_header')}\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")
diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py
index b9d12f2..8e2b601 100644
--- a/bot/handlers/admin/user_management.py
+++ b/bot/handlers/admin/user_management.py
@@ -1,18 +1,20 @@
import logging
import re
+import math
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional, Tuple, List, Any
-import math
-import aiosqlite
+
+from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from config.settings import Settings
-from db.database import (get_user, set_user_ban_status_db,
- get_user_by_telegram_username,
- get_banned_users_list_paginated,
- get_user_active_subscription_end_date)
+
+from db.dal import user_dal, subscription_dal
+from db.models import User, Subscription
+
from bot.services.panel_api_service import PanelApiService
+
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import (
get_back_to_admin_panel_keyboard, get_user_card_keyboard,
@@ -21,35 +23,49 @@ from bot.keyboards.inline.admin_keyboards import (
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_user_management_router")
-
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
+async def _get_user_model_by_input(session: AsyncSession,
+ input_text: str) -> Optional[User]:
+
+ user_model: Optional[User] = None
+ if input_text.isdigit():
+ try:
+ user_model = await user_dal.get_user_by_id(session,
+ int(input_text))
+ except ValueError:
+ pass
+ elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
+ user_model = await user_dal.get_user_by_username(
+ session, input_text[1:])
+ elif USERNAME_REGEX.match(input_text):
+ user_model = await user_dal.get_user_by_username(session, input_text)
+ return user_model
+
+
async def ban_user_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
-
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing")
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error processing ban prompt.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
prompt_text = _("admin_ban_user_prompt")
- if callback.message:
- try:
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
- except Exception as e:
- logging.warning(f"Edit failed: {e}")
- await callback.message.answer(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
+ try:
+ await callback.message.edit_text(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ except Exception as e:
+ logging.warning(
+ f"Edit failed for ban_user_prompt: {e}. Sending new message.")
+ await callback.message.answer(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+
await callback.answer()
await state.set_state(AdminStates.waiting_for_user_id_to_ban)
@@ -58,103 +74,90 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery,
async def process_user_input_to_ban_handler(message: types.Message,
state: FSMContext, i18n_data: dict,
settings: Settings,
- panel_service: PanelApiService):
-
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ panel_service: PanelApiService,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
- logging.error("i18n missing")
- await message.reply("Language error.")
+ await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- input_text = message.text.strip()
- user_to_ban_data: Optional[aiosqlite.Row] = None
- user_id_or_username_for_msg = input_text
- if input_text.isdigit():
- try:
- user_id_to_ban_val = int(input_text)
- user_to_ban_data = await get_user(user_id_to_ban_val)
- except ValueError:
- await message.answer(_("admin_invalid_user_id_format"))
- return
- elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
- user_to_ban_data = await get_user_by_telegram_username(input_text[1:])
- elif USERNAME_REGEX.match(input_text):
- user_to_ban_data = await get_user_by_telegram_username(input_text)
- else:
- await message.answer(
- _("admin_invalid_user_id_format") + " " +
- _("admin_invalid_username_format"))
- return
- if not user_to_ban_data:
+
+ input_text = message.text.strip() if message.text else ""
+ user_to_ban_model = await _get_user_model_by_input(session, input_text)
+
+ if not user_to_ban_model:
await message.answer(
_("admin_user_not_found_in_bot_db", user_id=input_text))
await state.clear()
return
- user_id_to_ban = user_to_ban_data['user_id']
- user_id_or_username_for_msg = f"@{user_to_ban_data['username']}" if user_to_ban_data.get(
- 'username') else str(user_id_to_ban)
- if user_id_to_ban == message.from_user.id or user_id_to_ban in settings.ADMIN_IDS:
+
+ user_id_to_ban = user_to_ban_model.user_id
+ user_display_for_msg = user_to_ban_model.username or str(user_id_to_ban)
+
+ if message.from_user and (user_id_to_ban == message.from_user.id
+ or user_id_to_ban in settings.ADMIN_IDS):
await message.answer(_("admin_cannot_ban_self_or_admin"))
await state.clear()
return
- if user_to_ban_data['is_banned']:
+
+ if user_to_ban_model.is_banned:
await message.answer(
_("admin_user_already_banned",
- user_id_or_username=user_id_or_username_for_msg))
+ user_id_or_username=user_display_for_msg))
await state.clear()
return
- panel_user_uuid = user_to_ban_data[
- 'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys(
- ) and user_to_ban_data['panel_user_uuid'] else None
- await set_user_ban_status_db(user_id_to_ban, is_banned=True)
- reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n)
- if panel_user_uuid:
- panel_ban_success = await panel_service.update_user_status_on_panel(
- panel_user_uuid, enable=False)
- if panel_ban_success:
- await message.answer(_(
- "admin_user_banned_success_panel_too",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
- else:
- await message.answer(_(
- "admin_user_banned_local_panel_fail",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
+
+ ban_success_local = await user_dal.set_user_ban_status(
+ session, user_id_to_ban, True)
+
+ if ban_success_local:
+
+ panel_ban_message_part = ""
+ if user_to_ban_model.panel_user_uuid:
+ panel_ban_api_success = await panel_service.update_user_status_on_panel(
+ user_to_ban_model.panel_user_uuid, enable=False)
+ if panel_ban_api_success:
+ panel_ban_message_part = _("admin_panel_ban_success_part")
+ else:
+ panel_ban_message_part = _("admin_panel_ban_fail_part")
+
+ await session.commit()
+ await message.answer(_("admin_user_banned_success_combined",
+ user_id_or_username=user_display_for_msg,
+ panel_status_part=panel_ban_message_part),
+ reply_markup=get_back_to_admin_panel_keyboard(
+ current_lang, i18n))
else:
- await message.answer(_(
- "admin_user_banned_local_no_panel_uuid",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
+ await session.rollback()
+ await message.answer(_("admin_user_ban_failed_local_db_error"))
+
await state.clear()
async def unban_user_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
-
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing")
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error processing unban prompt.",
+ show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
prompt_text = _("admin_unban_user_prompt")
- if callback.message:
- try:
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
- except:
- await callback.message.answer(
- prompt_text,
- reply_markup=get_back_to_admin_panel_keyboard(
- current_lang, i18n))
+ try:
+ await callback.message.edit_text(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ except Exception as e:
+ logging.warning(
+ f"Edit failed for unban_user_prompt: {e}. Sending new message.")
+ await callback.message.answer(
+ prompt_text,
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+
await callback.answer()
await state.set_state(AdminStates.waiting_for_user_id_to_unban)
@@ -164,131 +167,168 @@ async def process_user_input_to_unban_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
- panel_service: PanelApiService):
-
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ panel_service: PanelApiService,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
- logging.error("i18n missing")
- await message.reply("Language error.")
+ await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- input_text = message.text.strip()
- user_to_unban_data: Optional[aiosqlite.Row] = None
- user_id_or_username_for_msg = input_text
- if input_text.isdigit():
- try:
- user_id_to_unban_val = int(input_text)
- user_to_unban_data = await get_user(user_id_to_unban_val)
- except ValueError:
- await message.answer(_("admin_invalid_user_id_format"))
- return
- elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
- user_to_unban_data = await get_user_by_telegram_username(input_text[1:]
- )
- elif USERNAME_REGEX.match(input_text):
- user_to_unban_data = await get_user_by_telegram_username(input_text)
- else:
- await message.answer(
- _("admin_invalid_user_id_format") + " " +
- _("admin_invalid_username_format"))
- return
- if not user_to_unban_data:
+
+ input_text = message.text.strip() if message.text else ""
+ user_to_unban_model = await _get_user_model_by_input(session, input_text)
+
+ if not user_to_unban_model:
await message.answer(
_("admin_user_not_found_in_bot_db", user_id=input_text))
await state.clear()
return
- user_id_to_unban = user_to_unban_data['user_id']
- user_id_or_username_for_msg = f"@{user_to_unban_data['username']}" if user_to_unban_data.get(
- 'username') else str(user_id_to_unban)
- if not user_to_unban_data['is_banned']:
+
+ user_id_to_unban = user_to_unban_model.user_id
+ user_display_for_msg = user_to_unban_model.username or str(
+ user_id_to_unban)
+
+ if not user_to_unban_model.is_banned:
await message.answer(
_("admin_user_not_banned",
- user_id_or_username=user_id_or_username_for_msg))
+ user_id_or_username=user_display_for_msg))
await state.clear()
return
- panel_user_uuid = user_to_unban_data[
- 'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys(
- ) and user_to_unban_data['panel_user_uuid'] else None
- await set_user_ban_status_db(user_id_to_unban, is_banned=False)
- reply_markup_val = get_back_to_admin_panel_keyboard(current_lang, i18n)
- if panel_user_uuid:
- panel_unban_success = await panel_service.update_user_status_on_panel(
- panel_user_uuid, enable=True)
- if panel_unban_success:
- await message.answer(_(
- "admin_user_unbanned_success_panel_too",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
- else:
- await message.answer(_(
- "admin_user_unbanned_local_panel_fail",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
+
+ unban_success_local = await user_dal.set_user_ban_status(
+ session, user_id_to_unban, False)
+
+ if unban_success_local:
+ panel_unban_message_part = ""
+ if user_to_unban_model.panel_user_uuid:
+ panel_unban_api_success = await panel_service.update_user_status_on_panel(
+ user_to_unban_model.panel_user_uuid, enable=True)
+ if panel_unban_api_success:
+ panel_unban_message_part = _("admin_panel_unban_success_part")
+ else:
+ panel_unban_message_part = _("admin_panel_unban_fail_part")
+
+ await session.commit()
+ await message.answer(_("admin_user_unbanned_success_combined",
+ user_id_or_username=user_display_for_msg,
+ panel_status_part=panel_unban_message_part),
+ reply_markup=get_back_to_admin_panel_keyboard(
+ current_lang, i18n))
else:
- await message.answer(_(
- "admin_user_unbanned_local_no_panel_uuid",
- user_id_or_username=user_id_or_username_for_msg),
- reply_markup=reply_markup_val)
+ await session.rollback()
+ await message.answer(_("admin_user_unban_failed_local_db_error"))
+
await state.clear()
async def view_banned_users_handler(callback: types.CallbackQuery,
- i18n_data: dict, settings: Settings,
- state: FSMContext):
+ state: FSMContext, i18n_data: dict,
+ settings: Settings, session: AsyncSession):
await state.clear()
- current_page = 0
+ current_page_idx = 0
if ":" in callback.data and callback.data.count(":") == 2:
try:
- current_page = int(callback.data.split(":")[-1])
+ current_page_idx = int(callback.data.split(":")[-1])
except ValueError:
- current_page = 0
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ current_page_idx = 0
+
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error displaying banned users.",
+ show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- banned_users, total_banned = await get_banned_users_list_paginated(
- limit=settings.LOGS_PAGE_SIZE,
- offset=current_page * settings.LOGS_PAGE_SIZE)
+ page_size = settings.LOGS_PAGE_SIZE
+ offset = current_page_idx * page_size
- if not callback.message:
- await callback.answer("Error.")
- return
- if total_banned == 0:
+ banned_user_models, total_banned_count = await user_dal.get_banned_users_paginated(
+ session, limit=page_size, offset=offset)
+
+ if total_banned_count == 0:
await callback.message.edit_text(
_("admin_no_banned_users"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
else:
- total_pages = math.ceil(
- total_banned /
- settings.LOGS_PAGE_SIZE) if settings.LOGS_PAGE_SIZE > 0 else 1
+ total_pages = math.ceil(total_banned_count /
+ page_size) if page_size > 0 else 1
await callback.message.edit_text(
text=_("admin_banned_list_title",
- current_page=current_page + 1,
+ current_page=current_page_idx + 1,
total_pages=max(1, total_pages)),
- reply_markup=get_banned_users_keyboard(banned_users, current_page,
- total_banned, i18n,
+ reply_markup=get_banned_users_keyboard(banned_user_models,
+ current_page_idx,
+ total_banned_count, i18n,
current_lang, settings))
await callback.answer()
+async def _show_user_card_actual(target_message: types.Message,
+ user_id_to_show: int,
+ banned_list_page_to_return: int,
+ i18n_data: dict, settings: Settings,
+ panel_service: PanelApiService,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ if not i18n: return
+
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+
+ user_model = await user_dal.get_user_by_id(session, user_id_to_show)
+ if not user_model:
+ await target_message.edit_text(
+ _("admin_user_not_found_in_bot_db", user_id=user_id_to_show),
+ reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+ return
+
+ user_display_name = user_model.first_name or (f"@{user_model.username}"
+ if user_model.username else
+ f"ID: {user_id_to_show}")
+
+ sub_end_date_str = await subscription_dal.get_user_active_subscription_end_date_str(
+ session, user_id_to_show) or _("user_card_sub_na")
+
+ reg_date_display = user_model.registration_date.strftime(
+ '%Y-%m-%d %H:%M') if user_model.registration_date else "N/A"
+
+ card_text = _("user_card_info",
+ user_id=user_model.user_id,
+ username=user_model.username or "N/A",
+ first_name=user_model.first_name or "",
+ last_name=user_model.last_name or "",
+ language_code=user_model.language_code or "N/A",
+ panel_user_uuid=user_model.panel_user_uuid or "N/A",
+ ban_status=_("user_card_banned")
+ if user_model.is_banned else _("user_card_active"),
+ reg_date=reg_date_display,
+ sub_end_date=sub_end_date_str)
+ await target_message.edit_text(
+ text=
+ f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",
+ reply_markup=get_user_card_keyboard(user_id_to_show,
+ bool(user_model.is_banned), i18n,
+ current_lang,
+ banned_list_page_to_return),
+ parse_mode="HTML")
+
+
@router.callback_query(F.data.startswith("admin_user_card:"))
async def show_user_card_handler(callback: types.CallbackQuery,
+ state: FSMContext,
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
- state: FSMContext,
+ session: AsyncSession,
force_user_id: Optional[int] = None,
force_page: Optional[int] = None):
-
await state.clear()
+
user_id_to_show = 0
banned_list_page_to_return = 0
+
if force_user_id is not None and force_page is not None:
user_id_to_show = force_user_id
banned_list_page_to_return = force_page
@@ -300,281 +340,210 @@ async def show_user_card_handler(callback: types.CallbackQuery,
except (IndexError, ValueError):
await callback.answer("Invalid user card data.", show_alert=True)
return
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error displaying user card.", show_alert=True)
return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_data = await get_user(user_id_to_show)
- if not callback.message:
- await callback.answer("Error.")
- return
- if not user_data:
- await callback.message.edit_text(
- _("admin_user_not_found_in_bot_db", user_id=user_id_to_show),
- reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
+
+ await _show_user_card_actual(
+ target_message=callback.message,
+ user_id_to_show=user_id_to_show,
+ banned_list_page_to_return=banned_list_page_to_return,
+ i18n_data=i18n_data,
+ settings=settings,
+ panel_service=panel_service,
+ session=session)
+ if force_user_id is None:
await callback.answer()
- return
- user_display_name = user_data['first_name'] or (
- f"@{user_data['username']}"
- if user_data.get('username') else f"ID: {user_id_to_show}")
- sub_end_date_str = await get_user_active_subscription_end_date(
- user_id_to_show) or _("user_card_sub_na")
- reg_date_from_db = user_data[
- 'registration_date_str'] if 'registration_date_str' in user_data.keys(
- ) else "N/A"
- reg_date_display = reg_date_from_db[:
- 10] if reg_date_from_db and reg_date_from_db != "N/A" else "N/A"
- card_text = _(
- "user_card_info",
- user_id=user_data['user_id'],
- username=user_data.get('username', "N/A"),
- first_name=user_data.get('first_name', ""),
- last_name=user_data.get('last_name', ""),
- language_code=user_data.get('language_code', "N/A"),
- panel_user_uuid=user_data.get('panel_user_uuid', "N/A"),
- ban_status=_(key="user_card_banned") if user_data['is_banned'] else _(
- key="user_card_active"),
- reg_date=reg_date_display,
- sub_end_date=sub_end_date_str)
- await callback.message.edit_text(
- text=
- f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",
- reply_markup=get_user_card_keyboard(user_id_to_show,
- bool(user_data['is_banned']), i18n,
- current_lang,
- banned_list_page_to_return),
- parse_mode="HTML")
- if force_user_id is None: await callback.answer()
-@router.callback_query(F.data.startswith("admin_unban_confirm:"))
-async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict,
- settings: Settings):
-
+async def _confirm_action_handler(callback: types.CallbackQuery,
+ i18n_data: dict, settings: Settings,
+ session: AsyncSession, action_type: str):
try:
+
_, user_id_str, page_str = callback.data.split(":")
user_id = int(user_id_str)
banned_list_page = int(page_str)
- except:
- await callback.answer("Invalid data", show_alert=True)
+ except (ValueError, IndexError):
+ await callback.answer("Invalid confirmation data.", show_alert=True)
return
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error processing confirmation.",
+ show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_data = await get_user(user_id)
- user_display = (user_data['first_name'] or
- (f"@{user_data['username']}" if user_data.get('username')
- else f"ID {user_id}")) if user_data else f"ID {user_id}"
- if callback.message:
- await callback.message.edit_text(
- text=_("admin_confirm_unban_prompt",
- user_display=user_display,
- user_id=user_id),
- reply_markup=get_confirmation_keyboard(
- yes_callback_data=
- f"admin_unban_do:{user_id}:{banned_list_page}",
- no_callback_data=
- f"admin_user_card:{user_id}:{banned_list_page}",
- i18n_instance=i18n,
- lang=current_lang),
- parse_mode="HTML")
+
+ user_model = await user_dal.get_user_by_id(session, user_id)
+ user_display = (user_model.first_name or
+ (f"@{user_model.username}" if user_model.username else
+ f"ID {user_id}")) if user_model else f"ID {user_id}"
+
+ prompt_key = f"admin_confirm_{action_type}_prompt"
+ yes_callback = f"admin_{action_type}_do:{user_id}:{banned_list_page}"
+ no_callback = f"admin_user_card:{user_id}:{banned_list_page}"
+
+ await callback.message.edit_text(
+ text=_("admin_confirm_action_title",
+ action_text=_(f"{action_type}_verb_l")) + "\n\n" +
+ _(prompt_key, user_display=user_display, user_id=user_id),
+ reply_markup=get_confirmation_keyboard(yes_callback_data=yes_callback,
+ no_callback_data=no_callback,
+ i18n_instance=i18n,
+ lang=current_lang),
+ parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data.startswith("admin_ban_confirm:"))
async def confirm_ban_handler(callback: types.CallbackQuery, i18n_data: dict,
- settings: Settings):
+ settings: Settings, session: AsyncSession):
+ await _confirm_action_handler(callback, i18n_data, settings, session,
+ "ban")
+
+
+@router.callback_query(F.data.startswith("admin_unban_confirm:"))
+async def confirm_unban_handler(callback: types.CallbackQuery, i18n_data: dict,
+ settings: Settings, session: AsyncSession):
+ await _confirm_action_handler(callback, i18n_data, settings, session,
+ "unban")
+
+
+async def _do_ban_unban_action_handler(callback: types.CallbackQuery,
+ i18n_data: dict, settings: Settings,
+ panel_service: PanelApiService,
+ session: AsyncSession,
+ state: FSMContext, action_type: str):
try:
+
_, user_id_str, page_str = callback.data.split(":")
- user_id = int(user_id_str)
+ user_id_target = int(user_id_str)
banned_list_page = int(page_str)
- except:
- await callback.answer("Invalid data", show_alert=True)
+ except (ValueError, IndexError):
+ await callback.answer("Invalid action data.", show_alert=True)
return
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error processing action.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_data = await get_user(user_id)
- user_display = (user_data['first_name'] or
- (f"@{user_data['username']}" if user_data.get('username')
- else f"ID {user_id}")) if user_data else f"ID {user_id}"
- if callback.message:
- await callback.message.edit_text(
- text=_("admin_confirm_ban_prompt",
- user_display=user_display,
- user_id=user_id),
- reply_markup=get_confirmation_keyboard(
- yes_callback_data=f"admin_ban_do:{user_id}:{banned_list_page}",
- no_callback_data=
- f"admin_user_card:{user_id}:{banned_list_page}",
- i18n_instance=i18n,
- lang=current_lang),
- parse_mode="HTML")
- await callback.answer()
-
-@router.callback_query(F.data.startswith("admin_unban_do:"))
-async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
- settings: Settings,
- panel_service: PanelApiService,
- state: FSMContext):
-
- try:
- _, user_id_str, page_str = callback.data.split(":")
- user_id_to_unban = int(user_id_str)
- banned_list_page = int(page_str)
- except:
- await callback.answer("Invalid data", show_alert=True)
- return
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
- i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
- return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_to_unban_data = await get_user(user_id_to_unban)
- user_display_name = (
- user_to_unban_data['first_name'] or
- (f"@{user_to_unban_data['username']}"
- if user_to_unban_data.get('username') else f"ID {user_id_to_unban}")
- ) if user_to_unban_data else f"ID {user_id_to_unban}"
- if not user_to_unban_data or not user_to_unban_data['is_banned']:
- await callback.answer(_("admin_user_not_banned",
- user_id_or_username=user_display_name),
+ user_model = await user_dal.get_user_by_id(session, user_id_target)
+ if not user_model:
+ await callback.answer(_("admin_user_not_found_in_bot_db",
+ user_id=user_id_target),
show_alert=True)
- if callback.message:
- await show_user_card_handler(callback,
- i18n_data,
- settings,
- panel_service,
- state,
- force_user_id=user_id_to_unban,
- force_page=banned_list_page)
return
- await set_user_ban_status_db(user_id_to_unban, is_banned=False)
- panel_user_uuid = user_to_unban_data[
- 'panel_user_uuid'] if user_to_unban_data and 'panel_user_uuid' in user_to_unban_data.keys(
- ) and user_to_unban_data['panel_user_uuid'] else None
- if panel_user_uuid:
- if not await panel_service.update_user_status_on_panel(panel_user_uuid,
- enable=True):
- logging.warning(
- f"Panel status update fail for unban {user_id_to_unban}")
- await callback.answer(_("admin_user_unbanned_from_card",
- user_display=user_display_name,
- user_id=user_id_to_unban),
- show_alert=False)
- if callback.message:
- await show_user_card_handler(callback,
- i18n_data,
- settings,
- panel_service,
- state,
- force_user_id=user_id_to_unban,
- force_page=banned_list_page)
+
+ user_display_name = user_model.first_name or (f"@{user_model.username}"
+ if user_model.username else
+ f"ID {user_id_target}")
+
+ is_banning = action_type == "ban"
+
+ if is_banning:
+ if user_model.is_banned:
+ await callback.answer(_("admin_user_already_banned",
+ user_id_or_username=user_display_name),
+ show_alert=True)
+ return
+ if user_id_target == callback.from_user.id or user_id_target in settings.ADMIN_IDS:
+ await callback.answer(_("admin_cannot_ban_self_or_admin"),
+ show_alert=True)
+ return
+ else:
+ if not user_model.is_banned:
+ await callback.answer(_("admin_user_not_banned",
+ user_id_or_username=user_display_name),
+ show_alert=True)
+ return
+
+ action_success_local = await user_dal.set_user_ban_status(
+ session, user_id_target, is_banning)
+
+ if action_success_local:
+ panel_action_message = ""
+ if user_model.panel_user_uuid:
+ panel_api_success = await panel_service.update_user_status_on_panel(
+ user_model.panel_user_uuid, enable=not is_banning)
+ if not panel_api_success:
+ panel_action_message = _("admin_panel_status_update_fail_part")
+ logging.warning(
+ f"Panel status update failed for {action_type} of user {user_id_target} (panel: {user_model.panel_user_uuid})"
+ )
+
+ await session.commit()
+ alert_message_key = f"admin_user_{action_type}ned_from_card_alert"
+ await callback.answer(_(alert_message_key,
+ user_display=user_display_name,
+ user_id=user_id_target) + " " +
+ panel_action_message,
+ show_alert=False)
+ else:
+ await session.rollback()
+ await callback.answer(_(f"admin_user_{action_type}_failed_db_error"),
+ show_alert=True)
+ return
+
+ await show_user_card_handler(callback,
+ state,
+ i18n_data,
+ settings,
+ panel_service,
+ session,
+ force_user_id=user_id_target,
+ force_page=banned_list_page)
@router.callback_query(F.data.startswith("admin_ban_do:"))
async def do_ban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
- state: FSMContext):
- try:
- _, user_id_str, page_str = callback.data.split(":")
- user_id_to_ban = int(user_id_str)
- banned_list_page = int(page_str)
- except:
- await callback.answer("Invalid data", show_alert=True)
- return
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
- i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
- return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_to_ban_data = await get_user(user_id_to_ban)
- user_display_name = (
- user_to_ban_data['first_name'] or
- (f"@{user_to_ban_data['username']}"
- if user_to_ban_data.get('username') else f"ID {user_id_to_ban}")
- ) if user_to_ban_data else f"ID {user_id_to_ban}"
- if not user_to_ban_data:
- await callback.answer(_("admin_user_not_found_in_bot_db",
- user_id=user_id_to_ban),
- show_alert=True)
- return
- if user_to_ban_data['is_banned']:
- await callback.answer(_("admin_user_already_banned",
- user_id_or_username=user_display_name),
- show_alert=True)
- return
- if user_id_to_ban == callback.from_user.id or user_id_to_ban in settings.ADMIN_IDS:
- await callback.answer(_("admin_cannot_ban_self_or_admin"),
- show_alert=True)
- return
+ session: AsyncSession, state: FSMContext):
+ await _do_ban_unban_action_handler(callback, i18n_data, settings,
+ panel_service, session, state, "ban")
- await set_user_ban_status_db(user_id_to_ban, is_banned=True)
- panel_user_uuid = user_to_ban_data[
- 'panel_user_uuid'] if user_to_ban_data and 'panel_user_uuid' in user_to_ban_data.keys(
- ) and user_to_ban_data['panel_user_uuid'] else None
- if panel_user_uuid:
- if not await panel_service.update_user_status_on_panel(panel_user_uuid,
- enable=False):
- logging.warning(
- f"Panel status update fail for ban {user_id_to_ban}")
- await callback.answer(_("admin_user_banned_from_card",
- user_display=user_display_name,
- user_id=user_id_to_ban),
- show_alert=False)
- if callback.message:
- await show_user_card_handler(callback,
- i18n_data,
- settings,
- panel_service,
- state,
- force_user_id=user_id_to_ban,
- force_page=banned_list_page)
+
+@router.callback_query(F.data.startswith("admin_unban_do:"))
+async def do_unban_user_handler(callback: types.CallbackQuery, i18n_data: dict,
+ settings: Settings,
+ panel_service: PanelApiService,
+ session: AsyncSession, state: FSMContext):
+ await _do_ban_unban_action_handler(callback, i18n_data, settings,
+ panel_service, session, state, "unban")
@router.callback_query(F.data == "admin_action:main",
AdminStates.waiting_for_user_id_to_ban)
@router.callback_query(F.data == "admin_action:main",
AdminStates.waiting_for_user_id_to_unban)
-async def cancel_user_management_input_state(callback: types.CallbackQuery,
- state: FSMContext,
- settings: Settings,
- i18n_data: dict, bot: Bot):
-
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+async def cancel_user_management_input_state(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ settings: Settings,
+ i18n_data: dict,
+ bot: Bot,
+):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ if not i18n or not callback.message:
+ await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- if callback.message:
- try:
- await callback.message.edit_text(
- _("admin_action_cancelled_default"),
- reply_markup=get_admin_panel_keyboard(i18n, current_lang,
- settings))
- except:
- await callback.message.answer(
- _("admin_action_cancelled_default"),
- reply_markup=get_admin_panel_keyboard(i18n, current_lang,
- settings))
- await callback.answer(_("admin_action_cancelled_default"))
+
+ try:
+ await callback.message.edit_text(_("admin_action_cancelled_default"),
+ reply_markup=get_admin_panel_keyboard(
+ i18n, current_lang, settings))
+ except Exception:
+ await callback.message.answer(_("admin_action_cancelled_default"),
+ reply_markup=get_admin_panel_keyboard(
+ i18n, current_lang, settings))
+ await callback.answer(_("admin_action_cancelled_default_alert"))
await state.clear()
diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py
index 76e5333..6063652 100644
--- a/bot/handlers/user/payment.py
+++ b/bot/handlers/user/payment.py
@@ -1,35 +1,36 @@
import logging
import json
-import aiosqlite
import asyncio
-from datetime import datetime, timezone
+from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
from aiohttp import web
from aiogram import Bot
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import sessionmaker
+
from yookassa.domain.notification import WebhookNotification
-from yookassa.domain.models import Amount
+from yookassa.domain.models.amount import Amount as YooKassaAmount
+
+from db.dal import payment_dal, user_dal
-from db.database import get_db_connection_manager, _setup_db_connection, get_user
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
+from bot.services.payment_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
-from bot.services.payment_service import YooKassaService
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
-YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
-YOOKASSA_EVENT_REFUND_SUCCEEDED = 'refund.succeeded'
-async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
+async def process_successful_payment(session: AsyncSession, bot: Bot,
+ payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings,
panel_service: PanelApiService,
- yk_service: YooKassaService,
subscription_service: SubscriptionService,
referral_service: ReferralService):
metadata = payment_info_from_webhook.get("metadata", {})
@@ -43,6 +44,8 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
)
return
+
+ db_user = None
try:
user_id = int(user_id_str)
subscription_months = int(subscription_months_str)
@@ -50,262 +53,283 @@ async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
+
amount_data = payment_info_from_webhook.get("amount", {})
payment_value = float(amount_data.get("value", 0.0))
+
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user:
+ logging.error(
+ f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}."
+ )
+
+ await payment_dal.update_payment_status_by_db_id(
+ session, payment_db_id, "failed_user_not_found",
+ payment_info_from_webhook.get("id"))
+
+ return
+
except (TypeError, ValueError) as e:
logging.error(
f"Invalid metadata format for payment processing: {metadata} - {e}"
)
+
+ if payment_db_id_str and payment_db_id_str.isdigit():
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session, int(payment_db_id_str), "failed_metadata_error",
+ payment_info_from_webhook.get("id"))
+ except Exception as e_upd:
+ logging.error(
+ f"Failed to update payment status after metadata error: {e_upd}"
+ )
return
- final_end_date_for_user: Optional[datetime] = None
- applied_referee_bonus_days: Optional[int] = None
- base_subscription_end_date: Optional[datetime] = None
-
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- await db.execute(
- "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
- (payment_info_from_webhook.get("status", "succeeded"),
- payment_db_id, payment_info_from_webhook.get("id")))
-
- new_sub_details = await subscription_service.activate_subscription(
- user_id,
- subscription_months,
- payment_value,
- payment_db_id,
- db_conn=db,
- promo_code_id=promo_code_id)
-
- if new_sub_details and new_sub_details.get('end_date'):
- base_subscription_end_date = new_sub_details['end_date']
- final_end_date_for_user = base_subscription_end_date
-
- referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
- user_id, subscription_months, db_conn=db)
-
- if referral_bonus_info and referral_bonus_info.get(
- "referee_new_end_date"):
- final_end_date_for_user = referral_bonus_info[
- "referee_new_end_date"]
- applied_referee_bonus_days = referral_bonus_info.get(
- "referee_bonus_applied_days")
-
- await db.commit()
-
- user_lang = await subscription_service.get_user_language(
- user_id)
- _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
- )
-
- success_message = ""
- if applied_referee_bonus_days and final_end_date_for_user:
- referee_user_data = await get_user(user_id)
- inviter_name_for_msg = _("friend_placeholder")
-
- if referee_user_data and referee_user_data[
- 'referred_by_id'] is not None:
- inviter_user_data_for_msg = await get_user(
- referee_user_data['referred_by_id'])
- if inviter_user_data_for_msg and inviter_user_data_for_msg[
- 'first_name']:
- inviter_name_for_msg = inviter_user_data_for_msg[
- 'first_name']
-
- success_message = _(
- "payment_successful_with_referral_bonus",
- months=subscription_months,
- base_end_date=base_subscription_end_date.strftime(
- '%Y-%m-%d')
- if base_subscription_end_date else "N/A",
- bonus_days=applied_referee_bonus_days,
- final_end_date=final_end_date_for_user.strftime(
- '%Y-%m-%d'),
- inviter_name=inviter_name_for_msg)
- elif final_end_date_for_user:
- success_message = _(
- "payment_successful",
- months=subscription_months,
- end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
- else:
- logging.error(
- f"Critical error: final_end_date_for_user is None for user {user_id}"
- )
- success_message = _("payment_successful_error_details")
-
- try:
- await bot.send_message(user_id, success_message)
- except Exception as e:
- logging.error(
- f"Failed to send final payment success message to user {user_id}: {e}"
- )
- else:
- logging.error(
- f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}"
- )
- await db.rollback()
- except Exception as e:
+ try:
+ yk_payment_id_from_hook = payment_info_from_webhook.get("id")
+ updated_payment_record = await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_db_id=payment_db_id,
+ new_status=payment_info_from_webhook.get("status", "succeeded"),
+ yk_payment_id=yk_payment_id_from_hook)
+ if not updated_payment_record:
logging.error(
- f"Error during process_successful_payment transaction for user {user_id}: {e}",
- exc_info=True)
- await db.rollback()
- try:
- user_lang_for_error = await subscription_service.get_user_language(
- user_id)
- _err = lambda key, **kwargs: i18n.gettext(
- user_lang_for_error, key, **kwargs)
- await bot.send_message(user_id,
- _err("error_processing_your_payment"))
- except Exception as notify_err:
- logging.error(
- f"Failed to send error notification to user {user_id}: {notify_err}"
- )
+ f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}"
+ )
+ raise Exception(
+ f"DB Error: Could not update payment record {payment_db_id}")
+
+ activation_details = await subscription_service.activate_subscription(
+ session,
+ user_id,
+ subscription_months,
+ payment_value,
+ payment_db_id,
+ promo_code_id_from_payment=promo_code_id)
+
+ if not activation_details or not activation_details.get('end_date'):
+ logging.error(
+ f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}"
+ )
+ raise Exception(
+ f"Subscription Error: Failed to activate for user {user_id}")
+
+ base_subscription_end_date = activation_details['end_date']
+ final_end_date_for_user = base_subscription_end_date
+ applied_promo_bonus_days = activation_details.get(
+ "applied_promo_bonus_days", 0)
+
+ referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
+ session, user_id, subscription_months)
+ applied_referee_bonus_days_from_referral: Optional[int] = None
+ if referral_bonus_info and referral_bonus_info.get(
+ "referee_new_end_date"):
+ final_end_date_for_user = referral_bonus_info[
+ "referee_new_end_date"]
+ applied_referee_bonus_days_from_referral = referral_bonus_info.get(
+ "referee_bonus_applied_days")
+
+ user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
+ _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
+
+ success_message = ""
+ if applied_referee_bonus_days_from_referral and final_end_date_for_user:
+ inviter_name_display = _("friend_placeholder")
+ if db_user and db_user.referred_by_id:
+ inviter = await user_dal.get_user_by_id(
+ session, db_user.referred_by_id)
+ if inviter and inviter.first_name:
+ inviter_name_display = inviter.first_name
+ elif inviter and inviter.username:
+ inviter_name_display = f"@{inviter.username}"
+
+ success_message = _(
+ "payment_successful_with_referral_bonus",
+ months=subscription_months,
+ base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
+ bonus_days=applied_referee_bonus_days_from_referral,
+ final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
+ inviter_name=inviter_name_display)
+ elif applied_promo_bonus_days > 0 and final_end_date_for_user:
+ success_message = _(
+ "payment_successful_with_promo",
+ months=subscription_months,
+ bonus_days=applied_promo_bonus_days,
+ end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
+ elif final_end_date_for_user:
+ success_message = _(
+ "payment_successful",
+ months=subscription_months,
+ end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
+ else:
+ logging.error(
+ f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
+ )
+ success_message = _("payment_successful_error_details")
+
+ try:
+ await bot.send_message(user_id, success_message)
+ except Exception as e_notify:
+ logging.error(
+ f"Failed to send final payment success message to user {user_id}: {e_notify}"
+ )
+
+ except Exception as e_process:
+ logging.error(
+ f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
+ exc_info=True)
+
+ raise
-async def process_cancelled_payment(bot: Bot, payment_info_from_webhook: dict,
+async def process_cancelled_payment(session: AsyncSession, bot: Bot,
+ payment_info_from_webhook: dict,
i18n: JsonI18n, settings: Settings):
+
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
payment_db_id_str = metadata.get("payment_db_id")
+
if not user_id_str or not payment_db_id_str:
logging.warning(
- f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}"
+ f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
)
return
try:
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
- logging.error(f"Invalid metadata in cancelled payment: {metadata}")
- return
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await db.execute(
- "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
- (payment_info_from_webhook.get("status", "canceled"),
- payment_db_id, payment_info_from_webhook.get("id")))
- await db.commit()
- user_lang = getattr(settings, 'DEFAULT_LANGUAGE', 'en')
- _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
- try:
- await bot.send_message(user_id, _("payment_failed"))
- except Exception as e:
logging.error(
- f"Failed to send payment cancellation message to user {user_id}: {e}"
- )
+ f"Invalid metadata in cancelled payment webhook: {metadata}")
+ return
+
+ try:
+ updated_payment = await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_db_id=payment_db_id,
+ new_status=payment_info_from_webhook.get("status", "canceled"),
+ yk_payment_id=payment_info_from_webhook.get("id"))
+
+ if updated_payment:
+ logging.info(
+ f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}."
+ )
+ else:
+ logging.warning(
+ f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}."
+ )
+
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ user_lang = settings.DEFAULT_LANGUAGE
+ if db_user and db_user.language_code: user_lang = db_user.language_code
+
+ _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
+ await bot.send_message(user_id, _("payment_failed"))
+
+ except Exception as e_process_cancel:
+ logging.error(
+ f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}",
+ exc_info=True)
+ raise
async def yookassa_webhook_route(request: web.Request):
- logging.info(
- f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}"
- )
+
try:
bot: Bot = request.app['bot']
i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings']
- yk_service: YooKassaService = request.app['yookassa_service']
panel_service: PanelApiService = request.app['panel_service']
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
- except KeyError as e:
+ async_session_factory: sessionmaker = request.app[
+ 'async_session_factory']
+ except KeyError as e_app_ctx:
logging.error(
- f"KeyError accessing app context in yookassa_webhook_route: {e}.",
+ f"KeyError accessing app context in yookassa_webhook_route: {e_app_ctx}.",
exc_info=True)
- return web.Response(status=500,
- text="Internal Server Error: Missing app context")
+ return web.Response(
+ status=500,
+ text="Internal Server Error: Missing app context component")
+
try:
event_json = await request.json()
+
notification_object = WebhookNotification(event_json)
payment_data_from_notification = notification_object.object
+
logging.info(
- f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
+ f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
+ f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
)
+
if not payment_data_from_notification or not hasattr(
payment_data_from_notification,
'metadata') or payment_data_from_notification.metadata is None:
logging.error(
- f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata."
+ f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process."
)
return web.Response(status=200, text="ok_error_no_metadata")
- payment_dict_for_processing = {}
- if hasattr(payment_data_from_notification, 'model_dump'):
- payment_dict_for_processing = payment_data_from_notification.model_dump(
- exclude_none=True)
- if 'amount' in payment_dict_for_processing and isinstance(
- payment_dict_for_processing['amount'], Amount):
- amount_obj = payment_dict_for_processing['amount']
- payment_dict_for_processing['amount'] = {
- "value": str(amount_obj.value),
- "currency": str(amount_obj.currency)
- }
- elif 'amount' in payment_dict_for_processing and not isinstance(
- payment_dict_for_processing['amount'], dict):
- amount_obj_original = payment_data_from_notification.amount
- payment_dict_for_processing['amount'] = {
- "value": str(amount_obj_original.value),
- "currency": str(amount_obj_original.currency)
- } if hasattr(amount_obj_original, 'value') and hasattr(
- amount_obj_original, 'currency') else {
- "value": "0.0",
- "currency": "RUB"
- }
- elif hasattr(payment_data_from_notification, 'amount') and hasattr(
- payment_data_from_notification.amount, 'value') and hasattr(
- payment_data_from_notification.amount, 'currency'):
- amount_obj = payment_data_from_notification.amount
- payment_dict_for_processing = {
- "id":
- str(payment_data_from_notification.id),
- "status":
- str(payment_data_from_notification.status),
- "paid":
- bool(payment_data_from_notification.paid),
- "amount": {
- "value": str(amount_obj.value),
- "currency": str(amount_obj.currency)
- },
- "metadata":
- dict(payment_data_from_notification.metadata)
- if payment_data_from_notification.metadata else {},
- "description":
- str(payment_data_from_notification.description)
- if payment_data_from_notification.description else None
- }
- else:
- logging.error(
- f"Could not serialize payment_data for payment {payment_data_from_notification.id}"
- )
- return web.Response(status=200, text="ok_error_serialization")
+
+ payment_dict_for_processing = {
+ "id":
+ str(payment_data_from_notification.id),
+ "status":
+ str(payment_data_from_notification.status),
+ "paid":
+ bool(payment_data_from_notification.paid),
+ "amount": {
+ "value": str(payment_data_from_notification.amount.value),
+ "currency": str(payment_data_from_notification.amount.currency)
+ } if payment_data_from_notification.amount else {},
+ "metadata":
+ dict(payment_data_from_notification.metadata),
+ "description":
+ str(payment_data_from_notification.description)
+ if payment_data_from_notification.description else None,
+ }
+
async with payment_processing_lock:
- if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
- if payment_dict_for_processing.get(
- "paid") and payment_dict_for_processing.get(
- "status") == "succeeded":
- await process_successful_payment(
- bot, payment_dict_for_processing, i18n_instance,
- settings, panel_service, yk_service,
- subscription_service, referral_service)
- else:
- logging.warning(
- f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'"
- )
- elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
- await process_cancelled_payment(bot,
- payment_dict_for_processing,
- i18n_instance, settings)
+ async with async_session_factory() as session:
+ try:
+ if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
+ if payment_dict_for_processing.get(
+ "paid") and payment_dict_for_processing.get(
+ "status") == "succeeded":
+ await process_successful_payment(
+ session, bot, payment_dict_for_processing,
+ i18n_instance, settings, panel_service,
+ subscription_service, referral_service)
+ await session.commit()
+ else:
+ logging.warning(
+ f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
+ f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
+ f"paid='{payment_dict_for_processing.get('paid')}'"
+ )
+ elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
+ await process_cancelled_payment(
+ session, bot, payment_dict_for_processing,
+ i18n_instance, settings)
+ await session.commit()
+ except Exception as e_webhook_db_processing:
+ await session.rollback()
+ logging.error(
+ f"Error processing YooKassa webhook event '{notification_object.event}' "
+ f"for YK Payment ID {payment_dict_for_processing.get('id')} in DB transaction: {e_webhook_db_processing}",
+ exc_info=True)
+ return web.Response(
+ status=200, text="ok_internal_processing_error_logged")
+
return web.Response(status=200, text="ok")
+
except json.JSONDecodeError:
- logging.error("YooKassa Webhook: Invalid JSON.")
- return web.Response(status=200, text="ok_invalid_json")
- except KeyError as e:
+ logging.error("YooKassa Webhook: Invalid JSON received.")
+ return web.Response(status=400, text="bad_request_invalid_json")
+ except Exception as e_general_webhook:
logging.error(
- f"KeyError in yookassa_webhook_route after initial context access: {e}.",
+ f"YooKassa Webhook general processing error: {e_general_webhook}",
exc_info=True)
- return web.Response(
- status=500,
- text="Internal Server Error: Context error post-access")
- except Exception as e:
- logging.error(f"YooKassa Webhook processing error: {e}", exc_info=True)
- return web.Response(status=200, text="ok_internal_error")
+ return web.Response(status=200,
+ text="ok_general_internal_error_logged")
diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py
index 79af153..03dad66 100644
--- a/bot/handlers/user/promo_user.py
+++ b/bot/handlers/user/promo_user.py
@@ -3,6 +3,8 @@ import re
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from aiogram.utils.markdown import hcode
from config.settings import Settings
from bot.states.user_states import UserPromoStates
@@ -10,26 +12,26 @@ from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
from bot.middlewares.i18n import JsonI18n
-from aiogram.utils.markdown import hcode
from .start import send_main_menu
router = Router(name="user_promo_router")
SUSPICIOUS_SQL_KEYWORDS_REGEX = re.compile(
- r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|;\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
+ r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|"
+ r";\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
re.IGNORECASE)
SUSPICIOUS_CHARS_REGEX = re.compile(r"(--|#\s|;|\*\/|\/\*)")
+MAX_PROMO_CODE_INPUT_LENGTH = 100
async def prompt_promo_code_input(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ settings: Settings, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
- await callback.answer("Language error.", show_alert=True)
+ await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
@@ -44,8 +46,10 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
await callback.message.edit_text(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
- except Exception as e:
- logging.warning(f"Failed to edit message for promo prompt: {e}")
+ except Exception as e_edit:
+ logging.warning(
+ f"Failed to edit message for promo prompt: {e_edit}. Sending new one."
+ )
await callback.message.answer(
text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
@@ -53,73 +57,96 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
await callback.answer()
await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info(
- f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. FSM state: {await state.get_state()}"
- )
+ f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
+ f"FSM state: {await state.get_state()}")
@router.message(UserPromoStates.waiting_for_promo_code, F.text)
async def process_promo_code_input(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
promo_code_service: PromoCodeService,
- bot: Bot):
+ bot: Bot, session: AsyncSession):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
)
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_code_service:
- logging.error("Deps missing in process_promo_code_input")
- await message.reply("Service error. Please try again.")
+ logging.error(
+ "Dependencies (i18n or PromoCodeService) missing in process_promo_code_input"
+ )
+ await message.reply("Service error. Please try again later.")
await state.clear()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- code_input = message.text.strip()
+ code_input = message.text.strip() if message.text else ""
user = message.from_user
+
is_suspicious = False
- if SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
- code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input) or len(
- code_input) > 100:
+ if not code_input:
+ is_suspicious = True
+ logging.warning(f"Empty promo code input by user {user.id}.")
+ elif len(
+ code_input
+ ) > MAX_PROMO_CODE_INPUT_LENGTH or SUSPICIOUS_SQL_KEYWORDS_REGEX.search(
+ code_input) or SUSPICIOUS_CHARS_REGEX.search(code_input):
is_suspicious = True
logging.warning(
- f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'"
+ f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'"
)
response_to_user_text = ""
if is_suspicious:
- admin_notify_key = "admin_suspicious_promo_attempt_notification_no_username" if not user.username else "admin_suspicious_promo_attempt_notification"
- admin_lang = settings.DEFAULT_LANGUAGE
- _admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
- admin_notification_text = _admin(admin_notify_key,
- user_id=user.id,
- user_username=user.username or "N/A",
- user_first_name=user.first_name
- or "N/A",
- promo_code_input=hcode(code_input))
- try:
- await bot.send_message(settings.ADMIN_ID,
- admin_notification_text,
- parse_mode="HTML")
- except Exception as e_admin_notify:
- logging.error(
- f"Failed to send suspicious promo notification to admin: {e_admin_notify}"
- )
+
+ if settings.ADMIN_IDS:
+ admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username"
+
+ admin_lang = settings.DEFAULT_LANGUAGE
+ _admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
+ admin_notification_text = _admin(
+ admin_notify_key,
+ user_id=user.id,
+ user_username=user.username or "N/A",
+ user_first_name=user.first_name or "N/A",
+ promo_code_input=hcode(code_input))
+ for admin_id in settings.ADMIN_IDS:
+ try:
+ await bot.send_message(admin_id,
+ admin_notification_text,
+ parse_mode="HTML")
+ except Exception as e_admin_notify:
+ logging.error(
+ f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}"
+ )
+
response_to_user_text = _("promo_code_not_found",
- code=code_input.upper())
+ code=hcode(code_input.upper()))
else:
+
success, response_text_from_service = await promo_code_service.apply_promo_code(
- user.id, code_input, current_lang)
+ session, user.id, code_input, current_lang)
response_to_user_text = response_text_from_service
+ if success:
+ await session.commit()
+ logging.info(
+ f"Promo code '{code_input}' successfully applied for user {user.id}."
+ )
+ else:
+ await session.rollback()
+ logging.info(
+ f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}"
+ )
await message.answer(response_to_user_text,
reply_markup=get_back_to_main_menu_markup(
- current_lang, i18n))
+ current_lang, i18n),
+ parse_mode="HTML")
await state.clear()
logging.info(
- f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared."
+ f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
)
@@ -127,9 +154,9 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
UserPromoStates.waiting_for_promo_code)
async def cancel_promo_input_via_button(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
- i18n_data: dict, subscription_service: SubscriptionService):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ i18n_data: dict, subscription_service: SubscriptionService,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button")
@@ -140,21 +167,17 @@ async def cancel_promo_input_via_button(
f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state."
)
await state.clear()
- logging.info(
- f"State after clear for user {callback.from_user.id}: {await state.get_state()}"
- )
if callback.message:
- show_trial_button_on_back = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- callback.from_user.id):
- show_trial_button_on_back = True
await send_main_menu(callback,
settings,
i18n_data,
- show_trial_button_flag=show_trial_button_on_back,
+ subscription_service,
+ session,
is_edit=True)
else:
- await callback.answer("Promo code input cancelled.", show_alert=False)
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+ await callback.answer(_("promo_input_cancelled_short"),
+ show_alert=False)
diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py
index bf355b8..ea06161 100644
--- a/bot/handlers/user/referral.py
+++ b/bot/handlers/user/referral.py
@@ -1,67 +1,88 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
-from typing import Optional, Dict
+from typing import Optional, Union
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.services.referral_service import ReferralService
-from db.database import get_db_connection_manager
-from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard, get_back_to_main_menu_markup
+
+from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_referral_router")
-async def referral_command_handler(event: types.Message | types.CallbackQuery,
+async def referral_command_handler(event: Union[types.Message,
+ types.CallbackQuery],
settings: Settings, i18n_data: dict,
- referral_service: ReferralService,
- bot: Bot):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ referral_service: ReferralService, bot: Bot,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- target_message = event.message if isinstance(
+ target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
- if not target_message:
+ if not target_message_obj:
logging.error(
- "Target message is None in referral_command_handler from callback."
+ "Target message is None in referral_command_handler (possibly from callback without message)."
)
if isinstance(event, types.CallbackQuery):
- await event.answer("Error displaying referral info.")
+ await event.answer("Error displaying referral info.",
+ show_alert=True)
return
if not i18n or not referral_service:
- logging.error("Deps missing in referral_command_handler")
- await target_message.answer("Service error." if isinstance(
- event, types.Message) else "Service error.",
- parse_mode=None)
+ logging.error(
+ "Dependencies (i18n or ReferralService) missing in referral_command_handler"
+ )
+ await target_message_obj.answer(
+ "Service error. Please try again later.")
if isinstance(event, types.CallbackQuery): await event.answer()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- bot_info = await bot.get_me()
- bot_username = bot_info.username
+ try:
+ bot_info = await bot.get_me()
+ bot_username = bot_info.username
+ except Exception as e_bot_info:
+ logging.error(
+ f"Failed to get bot info for referral link: {e_bot_info}")
+ await target_message_obj.answer(_("error_generating_referral_link"))
+ if isinstance(event, types.CallbackQuery): await event.answer()
+ return
+
+ if not bot_username:
+ logging.error("Bot username is None, cannot generate referral link.")
+ await target_message_obj.answer(_("error_generating_referral_link"))
+ if isinstance(event, types.CallbackQuery): await event.answer()
+ return
+
inviter_user_id = event.from_user.id
referral_link = referral_service.generate_referral_link(
bot_username, inviter_user_id)
- bonus_info_parts = []
- if hasattr(settings,
- 'subscription_options') and settings.subscription_options:
- for months_period in sorted(settings.subscription_options.keys()):
- inv_bonus = settings.referral_bonus_inviter.get(months_period)
- ref_bonus = settings.referral_bonus_referee.get(months_period)
+ bonus_info_parts = []
+ if settings.subscription_options:
+
+ for months_period_key, _price in sorted(
+ settings.subscription_options.items()):
+
+ inv_bonus = settings.referral_bonus_inviter.get(months_period_key)
+ ref_bonus = settings.referral_bonus_referee.get(months_period_key)
if inv_bonus is not None or ref_bonus is not None:
bonus_info_parts.append(
_("referral_bonus_per_period",
- months=months_period,
+ months=months_period_key,
inviter_bonus_days=inv_bonus
- if inv_bonus is not None else _("no_bonus_days"),
+ if inv_bonus is not None else _("no_bonus_placeholder"),
referee_bonus_days=ref_bonus
- if ref_bonus is not None else _("no_bonus_days")))
+ if ref_bonus is not None else _("no_bonus_placeholder")))
+
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
+
text = _("referral_program_info_new",
referral_link=referral_link,
bonus_details=bonus_details_str)
@@ -72,29 +93,16 @@ async def referral_command_handler(event: types.Message | types.CallbackQuery,
await event.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
- elif isinstance(event, types.CallbackQuery):
+ elif isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
- except Exception as e:
- logging.warning(f"Failed to edit message for referral info: {e}")
-
+ except Exception as e_edit:
+ logging.warning(
+ f"Failed to edit message for referral info: {e_edit}. Sending new one."
+ )
await event.message.answer(text,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer()
-
-
-@router.callback_query(F.data == "copy_referral_link_ack")
-async def copy_referral_link_ack_callback_handler(
- callback: types.CallbackQuery, i18n_data: dict, settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
- i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- return await callback.answer("Language service error.",
- show_alert=True)
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- await callback.answer(text=_(key="referral_link_for_copying_reminder"),
- show_alert=False)
diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py
index 8cbb8e3..fc41f76 100644
--- a/bot/handlers/user/start.py
+++ b/bot/handlers/user/start.py
@@ -2,10 +2,12 @@ import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import CommandStart, Command
from aiogram.fsm.context import FSMContext
-from typing import Optional, Dict, Any, Callable, Awaitable
-from datetime import datetime, timezone, timedelta
+from typing import Optional, Union
+from sqlalchemy.ext.asyncio import AsyncSession
+from datetime import datetime, timezone
+
+from db.dal import user_dal
-from db.database import add_user_if_not_exists, update_user_language_code
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
@@ -13,228 +15,272 @@ from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
-from aiogram.types import InlineKeyboardMarkup
router = Router(name="user_start_router")
-async def send_main_menu(message_or_callback: types.Message
- | types.CallbackQuery,
+async def send_main_menu(target_event: Union[types.Message,
+ types.CallbackQuery],
settings: Settings,
i18n_data: dict,
- show_trial_button_flag: bool,
+ subscription_service: SubscriptionService,
+ session: AsyncSession,
is_edit: bool = False):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- answered_callback_internally = False
+
+ user_id = target_event.from_user.id
+ user_full_name = target_event.from_user.full_name
if not i18n:
- logging.error("i18n_instance missing in send_main_menu")
- target_mc_for_error = message_or_callback if isinstance(
- message_or_callback,
- types.Message) else message_or_callback.message
- error_text_fallback = "Error: Language service unavailable."
- if target_mc_for_error:
+ logging.error(
+ f"i18n_instance missing in send_main_menu for user {user_id}")
+ err_msg_fallback = "Error: Language service unavailable. Please try again later."
+ if isinstance(target_event, types.CallbackQuery):
try:
- await target_mc_for_error.answer(error_text_fallback)
- except Exception as e_ans:
- logging.error(
- f"Failed to send error message in send_main_menu: {e_ans}")
- if isinstance(message_or_callback, types.CallbackQuery):
- await message_or_callback.answer()
- answered_callback_internally = True
+ await target_event.answer(err_msg_fallback, show_alert=True)
+ except Exception:
+ pass
+ elif isinstance(target_event, types.Message) and hasattr(
+ target_event, 'chat') and target_event.chat:
+ try:
+ await target_event.chat.send_message(err_msg_fallback)
+ except Exception:
+ pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_full_name = message_or_callback.from_user.full_name
- text = _(key="main_menu_greeting", user_name=user_full_name)
- reply_markup: Optional[
- InlineKeyboardMarkup] = get_main_menu_inline_keyboard(
- current_lang, i18n, settings, show_trial_button_flag)
- target_message: Optional[types.Message] = None
- if isinstance(message_or_callback, types.Message):
- target_message = message_or_callback
- elif isinstance(message_or_callback, types.CallbackQuery):
- target_message = message_or_callback.message
- if not target_message:
+ show_trial_button_in_menu = False
+ if settings.TRIAL_ENABLED:
+ if hasattr(
+ subscription_service, 'has_had_any_subscription') and callable(
+ getattr(subscription_service, 'has_had_any_subscription')):
+ if not await subscription_service.has_had_any_subscription(
+ session, user_id):
+ show_trial_button_in_menu = True
+ else:
+ logging.error(
+ "Method has_had_any_subscription is missing in SubscriptionService for send_main_menu!"
+ )
+
+ text = _(key="main_menu_greeting", user_name=user_full_name)
+ reply_markup = get_main_menu_inline_keyboard(current_lang, i18n, settings,
+ show_trial_button_in_menu)
+
+ target_message_obj: Optional[types.Message] = None
+ if isinstance(target_event, types.Message):
+ target_message_obj = target_event
+ elif isinstance(target_event,
+ types.CallbackQuery) and target_event.message:
+ target_message_obj = target_event.message
+
+ if not target_message_obj:
logging.error(
- f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}."
+ f"send_main_menu: target_message_obj is None for event from user {user_id}."
)
- if isinstance(
- message_or_callback,
- types.CallbackQuery) and not answered_callback_internally:
- await message_or_callback.answer("Error displaying menu.")
- answered_callback_internally = True
+ if isinstance(target_event, types.CallbackQuery):
+ await target_event.answer(_("error_displaying_menu"),
+ show_alert=True)
return
try:
if is_edit:
- await target_message.edit_text(text, reply_markup=reply_markup)
+ await target_message_obj.edit_text(text, reply_markup=reply_markup)
else:
- await target_message.answer(text, reply_markup=reply_markup)
+ await target_message_obj.answer(text, reply_markup=reply_markup)
- if isinstance(
- message_or_callback,
- types.CallbackQuery) and not answered_callback_internally:
- await message_or_callback.answer()
- answered_callback_internally = True
+ if isinstance(target_event, types.CallbackQuery):
+ await target_event.answer()
except Exception as e_send_edit:
logging.warning(
- f"Failed to send/edit main menu (user: {message_or_callback.from_user.id}): {e_send_edit}."
+ f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
)
- if is_edit:
+ if is_edit and target_message_obj and hasattr(
+ target_message_obj, 'chat') and target_message_obj.chat:
try:
- await target_message.answer(text, reply_markup=reply_markup)
+ await target_message_obj.chat.send_message(
+ text, reply_markup=reply_markup)
except Exception as e_send_new:
logging.error(
- f"Also failed to send new main menu message: {e_send_new}")
- if isinstance(
- message_or_callback,
- types.CallbackQuery) and not answered_callback_internally:
- await message_or_callback.answer()
- answered_callback_internally = True
-
- if isinstance(message_or_callback,
- types.CallbackQuery) and not answered_callback_internally:
- logging.warning(
- f"Callback {message_or_callback.id} was not answered in send_main_menu main logic paths."
- )
- await message_or_callback.answer()
+ f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
+ )
+ if isinstance(target_event, types.CallbackQuery):
+ await target_event.answer(
+ _("error_occurred_try_again") if is_edit else None)
@router.message(CommandStart())
-async def start_command_handler(message: types.Message, state: FSMContext,
- settings: Settings, i18n_data: dict,
+async def start_command_handler(message: types.Message,
+ state: FSMContext,
+ settings: Settings,
+ i18n_data: dict,
subscription_service: SubscriptionService,
- bot: Bot):
-
+ session: AsyncSession,
+ command: Optional[CommandStart] = None):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n_instance not found")
- await message.answer("Language service error.")
- return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- user_id = message.from_user.id
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
+ user = message.from_user
+ user_id = user.id
+
referred_by_user_id: Optional[int] = None
- args = message.text.split()
- if len(args) > 1 and args[0] == "/start":
+ if command and command.args:
+ arg_payload = command.args
+ if arg_payload.startswith("ref_"):
+ try:
+ potential_referrer_id_str = arg_payload.split("_")[1]
+ if potential_referrer_id_str.isdigit():
+ potential_referrer_id = int(potential_referrer_id_str)
+ if potential_referrer_id != user_id:
+ referred_by_user_id = potential_referrer_id
+ except (IndexError, ValueError) as e:
+ logging.warning(
+ f"Could not parse referral from /start args '{arg_payload}': {e}"
+ )
+
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user:
+ user_data_to_create = {
+ "user_id": user_id,
+ "username": user.username,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "language_code": current_lang,
+ "referred_by_id": referred_by_user_id,
+ "registration_date": datetime.now(timezone.utc)
+ }
try:
- referral_param = args[1]
- if referral_param.startswith("ref_") and referral_param.split(
- "_")[1].isdigit():
- potential_referrer_id = int(referral_param.split("_")[1])
- if potential_referrer_id != user_id:
- referred_by_user_id = potential_referrer_id
- except (ValueError, IndexError) as e:
- logging.warning(f"Could not parse referral: '{args[1]}' - {e}")
- db_op_success, was_new_bot_user = await add_user_if_not_exists(
- user_id=user_id,
- username=message.from_user.username,
- first_name=message.from_user.first_name,
- last_name=message.from_user.last_name,
- lang_code=current_lang,
- referred_by_id=referred_by_user_id)
- if not db_op_success:
- await message.answer(_("error_occurred_processing_request"))
- return
- if referred_by_user_id:
- logging.info(
- f"User {user_id} started with referral from {referred_by_user_id}."
- )
- await message.answer(
- _(key="welcome", user_name=message.from_user.full_name))
- show_trial_button_in_menu = False
- if settings.TRIAL_ENABLED:
- if not await subscription_service.has_had_any_subscription(user_id):
- show_trial_button_in_menu = True
- logging.info(f"User {user_id} is eligible for a trial button.")
+ db_user = await user_dal.create_user(session, user_data_to_create)
- else:
logging.info(
- f"User {user_id} not eligible for trial button (already had a subscription)."
+ f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
)
+ except Exception as e_create:
+
+ logging.error(
+ f"Failed to add new user {user_id} to session: {e_create}",
+ exc_info=True)
+ await message.answer(_("error_occurred_processing_request"))
+ return
else:
- logging.info(f"Trial period is disabled in settings. No trial button.")
+ update_payload = {}
+ if db_user.language_code != current_lang:
+ update_payload["language_code"] = current_lang
+ if referred_by_user_id and db_user.referred_by_id is None:
+ update_payload["referred_by_id"] = referred_by_user_id
+ if user.username != db_user.username:
+ update_payload["username"] = user.username
+ if user.first_name != db_user.first_name:
+ update_payload["first_name"] = user.first_name
+ if user.last_name != db_user.last_name:
+ update_payload["last_name"] = user.last_name
+
+ if update_payload:
+ try:
+ await user_dal.update_user(session, user_id, update_payload)
+
+ logging.info(
+ f"Updated existing user {user_id} in session: {update_payload}"
+ )
+ except Exception as e_update:
+
+ logging.error(
+ f"Failed to update existing user {user_id} in session: {e_update}",
+ exc_info=True)
+
+ await message.answer(_(key="welcome", user_name=user.full_name))
await send_main_menu(message,
settings,
i18n_data,
- show_trial_button_flag=show_trial_button_in_menu)
+ subscription_service,
+ session,
+ is_edit=False)
@router.message(Command("language"))
@router.callback_query(F.data == "main_action:language")
-async def language_command_handler(event: types.Message | types.CallbackQuery,
- i18n_data: dict, settings: Settings):
-
+async def language_command_handler(
+ event: Union[types.Message, types.CallbackQuery],
+ i18n_data: dict,
+ settings: Settings,
+):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- target_message_for_reply: Optional[types.Message] = None
- is_callback = isinstance(event, types.CallbackQuery)
- answered_callback = False
- if is_callback:
- await event.answer()
- answered_callback = True
- target_message_for_reply = event.message
- else:
- target_message_for_reply = event
- if not i18n:
- logging.error("i18n instance is missing in language_command_handler.")
- error_message_text = "Language service error."
- if target_message_for_reply:
- await target_message_for_reply.answer(error_message_text)
- return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
text_to_send = _(key="choose_language")
- reply_markup_to_send = get_language_selection_keyboard(i18n, current_lang)
- if not target_message_for_reply:
- logging.warning("language_command_handler: No target message.")
+ reply_markup = get_language_selection_keyboard(i18n, current_lang)
+
+ target_message_obj = event.message if isinstance(
+ event, types.CallbackQuery) else event
+ if not target_message_obj:
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(_("error_occurred_try_again"), show_alert=True)
return
- if is_callback:
- try:
- await target_message_for_reply.edit_text(
- text_to_send, reply_markup=reply_markup_to_send)
- except Exception as e:
- logging.info(
- f"Could not edit for lang selection: {e}. Sending new.")
- await target_message_for_reply.answer(
- text_to_send, reply_markup=reply_markup_to_send)
+
+ if isinstance(event, types.CallbackQuery):
+ if event.message:
+ try:
+ await event.message.edit_text(text_to_send,
+ reply_markup=reply_markup)
+ except Exception:
+ await target_message_obj.answer(text_to_send,
+ reply_markup=reply_markup)
+ await event.answer()
else:
- await target_message_for_reply.answer(
- text_to_send, reply_markup=reply_markup_to_send)
+ await target_message_obj.answer(text_to_send,
+ reply_markup=reply_markup)
@router.callback_query(F.data.startswith("set_lang_"))
async def select_language_callback_handler(
callback: types.CallbackQuery, i18n_data: dict, settings: Settings,
- subscription_service: SubscriptionService):
+ subscription_service: SubscriptionService, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
- await callback.answer("Language service error.", show_alert=True)
+ await callback.answer("Service error or message context lost.",
+ show_alert=True)
+ return
+
+ try:
+ lang_code = callback.data.split("_")[2]
+ except IndexError:
+ await callback.answer("Error processing language selection.",
+ show_alert=True)
return
- lang_code = callback.data.split("_")[2]
user_id = callback.from_user.id
- await update_user_language_code(user_id, lang_code)
- i18n_data["current_language"] = lang_code
- _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
+ try:
+ updated = await user_dal.update_user_language(session, user_id,
+ lang_code)
+ if updated:
- await callback.answer(_(key="language_set_alert"))
+ i18n_data["current_language"] = lang_code
+ _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
+ await callback.answer(_(key="language_set_alert"))
+ logging.info(
+ f"User {user_id} language updated to {lang_code} in session.")
+ else:
+ await callback.answer("Could not set language.", show_alert=True)
+ return
+ except Exception as e_lang_update:
- show_trial_button_after_lang_change = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- user_id):
- show_trial_button_after_lang_change = True
-
- await send_main_menu(
- callback,
- settings,
- i18n_data,
- show_trial_button_flag=show_trial_button_after_lang_change,
- is_edit=True)
+ logging.error(
+ f"Error updating lang for user {user_id}: {e_lang_update}",
+ exc_info=True)
+ await callback.answer("Error setting language.", show_alert=True)
+ return
+ await send_main_menu(callback,
+ settings,
+ i18n_data,
+ subscription_service,
+ session,
+ is_edit=True)
@router.callback_query(F.data.startswith("main_action:"))
@@ -242,44 +288,48 @@ async def main_action_callback_handler(
callback: types.CallbackQuery, state: FSMContext, settings: Settings,
i18n_data: dict, bot: Bot, subscription_service: SubscriptionService,
referral_service: ReferralService, panel_service: PanelApiService,
- promo_code_service: PromoCodeService):
-
+ promo_code_service: PromoCodeService, session: AsyncSession):
action = callback.data.split(":")[1]
+ user_id = callback.from_user.id
+
from . import subscription as user_subscription_handlers
from . import referral as user_referral_handlers
from . import promo_user as user_promo_handlers
from . import trial_handler as user_trial_handlers
+
if not callback.message:
- logging.error(f"Callback {callback.id} no message for {action}")
- await callback.answer("Error.")
+ await callback.answer("Error: message context lost.", show_alert=True)
return
if action == "subscribe":
await user_subscription_handlers.display_subscription_options(
- callback, i18n_data, settings)
+ callback, i18n_data, settings, session)
elif action == "my_subscription":
+
await user_subscription_handlers.my_subscription_command_handler(
- callback, i18n_data, settings, panel_service, subscription_service)
+ callback, i18n_data, settings, panel_service, subscription_service,
+ session, bot)
elif action == "referral":
await user_referral_handlers.referral_command_handler(
- callback, settings, i18n_data, referral_service, bot)
+ callback, settings, i18n_data, referral_service, bot, session)
elif action == "apply_promo":
await user_promo_handlers.prompt_promo_code_input(
- callback, state, i18n_data, settings)
+ callback, state, i18n_data, settings, session)
elif action == "request_trial":
await user_trial_handlers.request_trial_confirmation_handler(
- callback, settings, i18n_data, subscription_service)
+ callback, settings, i18n_data, subscription_service, session)
elif action == "language":
+
await language_command_handler(callback, i18n_data, settings)
elif action == "back_to_main":
- show_trial_button_on_back = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- callback.from_user.id):
- show_trial_button_on_back = True
await send_main_menu(callback,
settings,
i18n_data,
- show_trial_button_flag=show_trial_button_on_back,
+ subscription_service,
+ session,
is_edit=True)
else:
- await callback.answer("Unknown action.", show_alert=True)
+ i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ _ = lambda key, **kwargs: i18n.gettext(
+ i18n_data.get("current_language"), key, **kw) if i18n else key
+ await callback.answer(_("main_menu_unknown_action"), show_alert=True)
diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py
index 7c04237..af3560c 100644
--- a/bot/handlers/user/subscription.py
+++ b/bot/handlers/user/subscription.py
@@ -1,15 +1,12 @@
import logging
-import aiosqlite
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
-from aiogram.fsm.context import FSMContext
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-from aiogram.types import InlineKeyboardMarkup
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
-from db.database import add_payment_record, get_db_connection_manager, _setup_db_connection
+from db.dal import payment_dal
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup)
@@ -21,307 +18,345 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="user_subscription_router")
-async def display_subscription_options(message_or_callback: types.Message
- | types.CallbackQuery, i18n_data: dict,
- settings: Settings):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+async def display_subscription_options(event: Union[types.Message,
+ types.CallbackQuery],
+ i18n_data: dict, settings: Settings,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing in display_subscription_options")
- target_msg = message_or_callback.message if isinstance(
- message_or_callback, types.CallbackQuery) else message_or_callback
- if target_msg: await target_msg.answer("Language service error.")
- if isinstance(message_or_callback, types.CallbackQuery):
- await message_or_callback.answer()
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
+ if not i18n:
+ err_msg = "Language service error."
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(err_msg, show_alert=True)
+ elif isinstance(event, types.Message):
+ await event.answer(err_msg)
return
- get_translation = lambda key, **kwargs: i18n.gettext(
- current_lang, key, **kwargs)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
+ text_content = get_text("select_subscription_period"
+ ) if settings.subscription_options else get_text(
+ "no_subscription_options_available")
- text = get_translation(
- "select_subscription_period"
- ) if settings.subscription_options else get_translation(
- "no_subscription_options_available")
reply_markup = get_subscription_options_keyboard(
- settings.subscription_options, currency_symbol_val, current_lang,
- i18n) if settings.subscription_options else None
+ settings.subscription_options, currency_symbol_val, current_lang, i18n
+ ) if settings.subscription_options else get_back_to_main_menu_markup(
+ current_lang, i18n)
- target_message = message_or_callback.message if isinstance(
- message_or_callback, types.CallbackQuery) else message_or_callback
- answered_callback = False
+ target_message_obj = event.message if isinstance(
+ event, types.CallbackQuery) else event
+ if not target_message_obj:
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(get_text("error_occurred_try_again"),
+ show_alert=True)
+ return
- if isinstance(message_or_callback, types.CallbackQuery):
-
- await message_or_callback.answer()
- answered_callback = True
-
- if target_message:
- if isinstance(message_or_callback, types.CallbackQuery):
- try:
- await target_message.edit_text(text, reply_markup=reply_markup)
- except Exception:
- await target_message.answer(text, reply_markup=reply_markup)
- else:
- await target_message.answer(text, reply_markup=reply_markup)
- elif isinstance(message_or_callback, types.Message):
- await message_or_callback.answer(text, reply_markup=reply_markup)
-
- if isinstance(message_or_callback,
- types.CallbackQuery) and not answered_callback:
- await message_or_callback.answer()
+ if isinstance(event, types.CallbackQuery):
+ try:
+ await target_message_obj.edit_text(text_content,
+ reply_markup=reply_markup)
+ except Exception:
+ await target_message_obj.answer(text_content,
+ reply_markup=reply_markup)
+ await event.answer()
+ else:
+ await target_message_obj.answer(text_content,
+ reply_markup=reply_markup)
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(
- callback: types.CallbackQuery, state: FSMContext, settings: Settings,
- i18n_data: dict):
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
+ session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error(
- "i18n missing in select_subscription_period_callback_handler")
- await callback.answer("Service error. Please try again.",
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
+ if not i18n or not callback.message:
+ await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
return
- get_translation = lambda key, **kwargs: i18n.gettext(
- current_lang, key, **kwargs)
try:
months = int(callback.data.split(":")[-1])
- except ValueError:
- logging.error(f"Invalid sub period: {callback.data}")
- await callback.answer(get_translation("error_try_again"),
- show_alert=True)
+ except (ValueError, IndexError):
+ logging.error(
+ f"Invalid subscription period in callback_data: {callback.data}")
+ await callback.answer(get_text("error_try_again"), show_alert=True)
return
- price = settings.subscription_options.get(months)
- if price is None:
- logging.error(f"Price not found for {months} months subscription.")
- await callback.answer(get_translation("error_try_again"),
- show_alert=True)
+ price_rub = settings.subscription_options.get(months)
+ if price_rub is None:
+ logging.error(
+ f"Price not found for {months} months subscription period in settings.subscription_options."
+ )
+ await callback.answer(get_text("error_try_again"), show_alert=True)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
- confirmation_text = get_translation("confirm_subscription_prompt",
- months=months,
- price=price,
- currency_symbol=currency_symbol_val)
- reply_markup = get_confirm_subscription_keyboard(months, price,
+ confirmation_text_content = get_text("confirm_subscription_prompt",
+ months=months,
+ price=f"{price_rub:.2f}",
+ currency_symbol=currency_symbol_val)
+ reply_markup = get_confirm_subscription_keyboard(months, price_rub,
currency_symbol_val,
current_lang, i18n)
- if callback.message:
- try:
- await callback.message.edit_text(confirmation_text,
- reply_markup=reply_markup)
- except Exception as e:
- logging.warning(f"Edit failed: {e}")
- await callback.message.answer(confirmation_text,
- reply_markup=reply_markup)
+ try:
+ await callback.message.edit_text(confirmation_text_content,
+ reply_markup=reply_markup)
+ except Exception as e_edit:
+ logging.warning(
+ f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
+ )
+ await callback.message.answer(confirmation_text_content,
+ reply_markup=reply_markup)
await callback.answer()
@router.callback_query(F.data.startswith("confirm_sub:"))
async def confirm_subscription_callback_handler(
- callback: types.CallbackQuery, state: FSMContext, settings: Settings,
- i18n_data: dict, yookassa_service: YooKassaService):
+ callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
+ yookassa_service: YooKassaService, session: AsyncSession):
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
- if not i18n:
- logging.error("i18n missing")
- await callback.answer("Language error.", show_alert=True)
- return
- get_translation = lambda key, **kwargs: i18n.gettext(
- current_lang, key, **kwargs)
- if not yookassa_service or not yookassa_service.configured:
- logging.error("YooKassa service missing or not configured")
- await callback.message.edit_text(
- get_translation("payment_service_unavailable")
- ) if callback.message else None
- await callback.answer(get_translation("payment_service_unavailable"),
+
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
+ if not i18n or not callback.message:
+
+ await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
return
+
+ if not yookassa_service or not yookassa_service.configured:
+ logging.error("YooKassa service is not configured or unavailable.")
+ target_msg_edit = callback.message
+ await target_msg_edit.edit_text(get_text("payment_service_unavailable")
+ )
+ await callback.answer(get_text("payment_service_unavailable_alert"),
+ show_alert=True)
+ return
+
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
- price = float(price_str)
- except ValueError:
- logging.error(f"Invalid confirm data: {callback.data}")
- await callback.answer(get_translation("error_try_again"),
- show_alert=True)
+ price_rub = float(price_str)
+ except (ValueError, IndexError):
+ logging.error(
+ f"Invalid confirmation data in callback: {callback.data}")
+ await callback.answer(get_text("error_try_again"), show_alert=True)
return
user_id = callback.from_user.id
- description = get_translation("payment_description_subscription",
- months=months)
- currency = settings.DEFAULT_CURRENCY_SYMBOL
- payment_metadata = {
+
+ payment_description = get_text("payment_description_subscription",
+ months=months)
+ currency_code_for_yk = "RUB"
+
+ payment_record_data = {
+ "user_id": user_id,
+ "amount": price_rub,
+ "currency": currency_code_for_yk,
+ "status": "pending_yookassa",
+ "description": payment_description,
+ "subscription_duration_months": months,
+ }
+ db_payment_record = None
+ try:
+ db_payment_record = await payment_dal.create_payment_record(
+ session, payment_record_data)
+ await session.commit()
+ logging.info(
+ f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
+ )
+ except Exception as e_db_payment:
+ await session.rollback()
+ logging.error(
+ f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
+ exc_info=True)
+ await callback.message.edit_text(
+ get_text("error_creating_payment_record"))
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ return
+
+ if not db_payment_record:
+ await callback.message.edit_text(
+ get_text("error_creating_payment_record"))
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ return
+
+ yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
- "description": description
+ "payment_db_id": str(db_payment_record.payment_id),
}
- payment_db_id = await add_payment_record(user_id, None, None, price,
- currency, "pending_creation",
- description, months, None)
- if not payment_db_id:
- if callback.message:
- await callback.message.edit_text(
- get_translation("error_creating_payment_record"))
- await callback.answer(show_alert=True)
- return
- payment_metadata["payment_db_id"] = str(payment_db_id)
- payment_response = await yookassa_service.create_payment(
- price, currency, description, payment_metadata)
+ receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
- if callback.message:
- if payment_response and payment_response.get("confirmation_url"):
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await db.execute(
- "UPDATE payments SET yookassa_payment_id = ?, idempotence_key = ?, status = ? WHERE payment_id = ?",
- (payment_response["id"],
- payment_response.get("idempotence_key"),
- payment_response["status"], payment_db_id))
- await db.commit()
+ payment_response_yk = await yookassa_service.create_payment(
+ amount=price_rub,
+ currency=currency_code_for_yk,
+ description=payment_description,
+ metadata=yookassa_metadata,
+ receipt_email=receipt_email_for_yk)
+
+ if payment_response_yk and payment_response_yk.get("confirmation_url"):
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session,
+ payment_db_id=db_payment_record.payment_id,
+ new_status=payment_response_yk.get("status", "pending"),
+ yk_payment_id=payment_response_yk.get("id"))
+ await session.commit()
+ except Exception as e_db_update_ykid:
+ await session.rollback()
+ logging.error(
+ f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
+ exc_info=True)
await callback.message.edit_text(
- get_translation(key="payment_link_message", months=months),
- reply_markup=get_payment_url_keyboard(
- payment_response["confirmation_url"], current_lang, i18n),
- disable_web_page_preview=False)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await db.execute(
- "UPDATE payments SET status = ? WHERE payment_id = ?",
- ("failed_creation", payment_db_id))
- await db.commit()
- await callback.message.edit_text(
- get_translation("error_payment_gateway"))
+ get_text("error_payment_gateway_link_failed"))
+ await callback.answer(get_text("error_try_again"), show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ get_text(key="payment_link_message", months=months),
+ reply_markup=get_payment_url_keyboard(
+ payment_response_yk["confirmation_url"], current_lang, i18n),
+ disable_web_page_preview=False)
+ else:
+ try:
+ await payment_dal.update_payment_status_by_db_id(
+ session, db_payment_record.payment_id, "failed_creation")
+ await session.commit()
+ except Exception as e_db_fail_create:
+ await session.rollback()
+ logging.error(
+ f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
+ exc_info=True)
+
+ logging.error(
+ f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
+ )
+ await callback.message.edit_text(get_text("error_payment_gateway"))
+
await callback.answer()
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
i18n_data: dict,
- settings: Settings):
- await display_subscription_options(callback, i18n_data, settings)
+ settings: Settings,
+ session: AsyncSession):
+ await display_subscription_options(callback, i18n_data, settings, session)
async def my_subscription_command_handler(
- message_event: types.Message | types.CallbackQuery, i18n_data: dict,
+ event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
settings: Settings, panel_service: PanelApiService,
- subscription_service: SubscriptionService):
- target_message = message_event.message if isinstance(
- message_event, types.CallbackQuery) else message_event
- user = message_event.from_user
- if isinstance(message_event, types.CallbackQuery):
- await message_event.answer()
+ subscription_service: SubscriptionService, session: AsyncSession,
+ bot: Bot):
+ target_message_obj = event.message if isinstance(
+ event, types.CallbackQuery) else event
+ user = event.from_user
- current_lang = i18n_data.get("current_language",
- getattr(settings, 'DEFAULT_LANGUAGE', 'en'))
+ if isinstance(event, types.CallbackQuery):
+ await event.answer()
+
+ current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
- if not i18n:
- logging.error("i18n missing")
- await target_message.answer("Lang error")
+ get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
+ if not i18n or not target_message_obj:
+ if isinstance(event, types.Message):
+ await event.answer(get_text("error_occurred_try_again"))
return
- get_translation = lambda key, **kwargs: i18n.gettext(
- current_lang, key, **kwargs)
+
if not panel_service or not subscription_service:
- logging.error("Services missing")
- await target_message.answer(
- get_translation("error_service_unavailable"))
+ logging.error(
+ "PanelService or SubscriptionService is missing in my_subscription_command_handler."
+ )
+ await target_message_obj.answer(get_text("error_service_unavailable"))
return
- active_sub = await subscription_service.get_active_subscription(user.id)
- sub_info_text = ""
- if active_sub:
- end_date_obj = active_sub.get('end_date')
- if isinstance(end_date_obj, str):
- try:
- end_date_obj = datetime.fromisoformat(
- end_date_obj.replace("Z", "+00:00"))
- except ValueError:
- logging.warning(
- f"Could not parse date string '{end_date_obj}'.")
- end_date_obj = datetime.now(timezone.utc)
+ active_sub_details = await subscription_service.get_active_subscription_details(
+ session, user.id)
- if not isinstance(end_date_obj, datetime):
- end_date_obj = datetime.now(timezone.utc)
- if end_date_obj.tzinfo is None:
- end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
+ sub_info_text_content = ""
+ if active_sub_details:
+ end_date_obj = active_sub_details.get('end_date')
+ days_left = 0
+ if end_date_obj:
+ if end_date_obj.tzinfo is None:
+ end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
+ days_left = (end_date_obj.date() - datetime.now().date()).days
- today_date_utc = datetime.now(timezone.utc).date()
- end_date_only = end_date_obj.date()
- days_left = (end_date_only - today_date_utc).days
+ actual_config_link = active_sub_details.get('config_link') or get_text(
+ "config_link_not_available")
- actual_config_link = get_translation("config_link_not_available")
- panel_user_uuid = active_sub.get('panel_user_uuid')
- if panel_user_uuid:
- panel_user_data = await panel_service.get_user_by_uuid(
- panel_user_uuid)
- if panel_user_data:
- if panel_user_data.get('subscriptionUrl'):
- actual_config_link = panel_user_data['subscriptionUrl']
- elif panel_user_data.get('shortUuid'):
- link = await panel_service.get_subscription_link(
- panel_user_data['shortUuid'])
- if link: actual_config_link = link
+ traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
+ traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
- traffic_limit_gb = get_translation("traffic_unlimited")
- traffic_used_gb = get_translation("traffic_na")
- if active_sub.get('traffic_limit_bytes'
- ) and active_sub['traffic_limit_bytes'] > 0:
- traffic_limit_gb = f"{active_sub['traffic_limit_bytes'] / (1024**3):.2f} GB"
- if active_sub.get('traffic_used_bytes') is not None:
- traffic_used_gb = f"{active_sub['traffic_used_bytes'] / (1024**3):.2f} GB"
+ traffic_limit_gb_str = get_text("traffic_unlimited")
+ if traffic_limit_bytes and traffic_limit_bytes > 0:
+ traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
- sub_info_text = get_translation(
+ traffic_used_gb_str = get_text("traffic_na")
+ if traffic_used_bytes is not None:
+ traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
+
+ sub_info_text_content = get_text(
"my_subscription_details",
- end_date=end_date_obj.strftime("%Y-%m-%d"),
+ end_date=end_date_obj.strftime("%Y-%m-%d")
+ if end_date_obj else "N/A",
days_left=max(0, days_left),
- status=active_sub.get(
- 'status_from_panel',
- get_translation('status_active')).capitalize(),
+ status=active_sub_details.get(
+ 'status_from_panel', get_text('status_active')).capitalize(),
config_link=actual_config_link,
- traffic_limit=traffic_limit_gb,
- traffic_used=traffic_used_gb)
+ traffic_limit=traffic_limit_gb_str,
+ traffic_used=traffic_used_gb_str)
else:
- sub_info_text = get_translation("subscription_not_active")
+ sub_info_text_content = get_text("subscription_not_active")
+ logging.info(
+ f"User {user.id} no active sub details for 'my_subscription'.")
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
- if isinstance(message_event,
- types.CallbackQuery) and message_event.message:
+
+ if isinstance(event, types.CallbackQuery) and event.message:
try:
- await message_event.message.edit_text(
- sub_info_text,
- reply_markup=reply_markup_val,
- parse_mode="HTML",
- disable_web_page_preview=True)
- except Exception as e:
- logging.warning(f"Edit 'my_sub' failed: {e}")
- await target_message.answer(sub_info_text,
+ await event.message.edit_text(sub_info_text_content,
+ reply_markup=reply_markup_val,
+ parse_mode="HTML",
+ disable_web_page_preview=True)
+ except Exception as e_edit:
+ logging.warning(
+ f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
+ )
+ await bot.send_message(chat_id=target_message_obj.chat.id,
+ text=sub_info_text_content,
+ reply_markup=reply_markup_val,
+ parse_mode="HTML",
+ disable_web_page_preview=True)
+ else:
+ await target_message_obj.answer(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
- else:
- await target_message.answer(sub_info_text,
- reply_markup=reply_markup_val,
- parse_mode="HTML",
- disable_web_page_preview=True)
@router.message(Command("connect"))
async def connect_command_handler(message: types.Message, i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
- subscription_service: SubscriptionService):
- """Handles the /connect command, showing subscription info."""
+ subscription_service: SubscriptionService,
+ session: AsyncSession, bot: Bot):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings,
- panel_service, subscription_service)
+ panel_service, subscription_service,
+ session, bot)
diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py
index 247af23..158fa9b 100644
--- a/bot/handlers/user/trial_handler.py
+++ b/bot/handlers/user/trial_handler.py
@@ -1,8 +1,8 @@
import logging
from aiogram import Router, F, types, Bot
-
-from typing import Optional, Dict, Any
-from datetime import datetime, timedelta, timezone
+from typing import Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from datetime import datetime
from config.settings import Settings
from bot.services.subscription_service import SubscriptionService
@@ -16,37 +16,44 @@ router = Router(name="user_trial_router")
async def request_trial_confirmation_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
- subscription_service: SubscriptionService):
+ subscription_service: SubscriptionService, session: AsyncSession):
user_id = callback.from_user.id
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
+
if not i18n or not callback.message:
- await callback.answer("Error.", show_alert=True)
+ await callback.answer(_("error_occurred_try_again"), show_alert=True)
return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
- show_trial_btn_if_fail = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- user_id):
- show_trial_btn_if_fail = True
+
+ show_trial_btn_in_menu_if_fail = False
+ if settings.TRIAL_ENABLED:
+ if not await subscription_service.has_had_any_subscription(
+ session, user_id):
+ show_trial_btn_in_menu_if_fail = True
+
if not settings.TRIAL_ENABLED:
await callback.message.edit_text(
_("trial_feature_disabled"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
- settings,
- show_trial_btn_if_fail))
+ settings, False))
await callback.answer()
return
- if await subscription_service.has_had_any_subscription(user_id):
+
+ if await subscription_service.has_had_any_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
settings, False))
await callback.answer()
return
+
traffic_gb_display = str(
settings.TRIAL_TRAFFIC_LIMIT_GB
) if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 else _(
"traffic_unlimited")
+
await callback.message.edit_text(
text=_("trial_confirm_prompt",
days=settings.TRIAL_DURATION_DAYS,
@@ -59,55 +66,58 @@ async def request_trial_confirmation_handler(
async def confirm_activate_trial_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
subscription_service: SubscriptionService,
- panel_service: PanelApiService, bot: Bot):
+ panel_service: PanelApiService, session: AsyncSession):
user_id = callback.from_user.id
+
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
+ _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
+ ) if i18n else key
if not i18n or not callback.message:
- await callback.answer("Error.", show_alert=True)
+ await callback.answer(_("error_occurred_try_again"), show_alert=True)
return
- _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
-
- show_trial_button_after_action = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- user_id):
- show_trial_button_after_action = True
if not settings.TRIAL_ENABLED:
await callback.answer(_("trial_feature_disabled"), show_alert=True)
+
await send_main_menu(callback,
settings,
i18n_data,
- show_trial_button_flag=False,
+ subscription_service,
+ session,
is_edit=True)
return
- if await subscription_service.has_had_any_subscription(user_id):
+ if await subscription_service.has_had_any_subscription(session, user_id):
await callback.answer(_("trial_already_had_subscription_or_trial"),
show_alert=True)
await send_main_menu(callback,
settings,
i18n_data,
- show_trial_button_flag=False,
+ subscription_service,
+ session,
is_edit=True)
return
activation_result = await subscription_service.activate_trial_subscription(
- user_id)
+ session, user_id)
+
final_message_text_in_chat = ""
+ show_trial_button_after_action = False
if activation_result and activation_result.get("activated"):
await callback.answer(_("trial_activated_alert"), show_alert=True)
- end_date = activation_result.get("end_date")
- config_link_for_trial = _("config_link_not_available")
+ end_date_obj = activation_result.get("end_date")
+ config_link_for_trial = activation_result.get("subscription_url") or _(
+ "config_link_not_available")
- if activation_result.get("subscription_url"):
- config_link_for_trial = activation_result["subscription_url"]
- elif activation_result.get("panel_short_uuid"):
- link = await panel_service.get_subscription_link(
+ if config_link_for_trial == _(
+ "config_link_not_available") and activation_result.get(
+ "panel_short_uuid"):
+ generated_link = await panel_service.get_subscription_link(
activation_result["panel_short_uuid"])
- if link: config_link_for_trial = link
+ if generated_link: config_link_for_trial = generated_link
traffic_gb_val = activation_result.get("traffic_gb",
settings.TRIAL_TRAFFIC_LIMIT_GB)
@@ -117,30 +127,34 @@ async def confirm_activate_trial_handler(
final_message_text_in_chat = _(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
- end_date=end_date.strftime('%Y-%m-%d') if isinstance(
- end_date, datetime) else "N/A",
+ end_date=end_date_obj.strftime('%Y-%m-%d') if isinstance(
+ end_date_obj, datetime) else "N/A",
config_link=config_link_for_trial,
traffic_gb=traffic_display)
- show_trial_button_after_action = False
else:
- message_key = activation_result.get(
+ message_key_from_service = activation_result.get(
"message_key", "trial_activation_failed"
) if activation_result else "trial_activation_failed"
- final_message_text_in_chat = _(message_key)
+ final_message_text_in_chat = _(message_key_from_service)
await callback.answer(final_message_text_in_chat, show_alert=True)
+ if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
+ session, user_id):
+ show_trial_button_after_action = True
- if callback.message:
- try:
- await callback.message.edit_text(
- final_message_text_in_chat,
- parse_mode="HTML",
- reply_markup=get_main_menu_inline_keyboard(
- current_lang, i18n, settings,
- show_trial_button_after_action),
- disable_web_page_preview=True)
- except Exception as e_edit:
- logging.warning(f"Could not edit trial result message: {e_edit}")
- await callback.message.answer(
+ try:
+ await callback.message.edit_text(
+ final_message_text_in_chat,
+ parse_mode="HTML",
+ reply_markup=get_main_menu_inline_keyboard(
+ current_lang, i18n, settings, show_trial_button_after_action),
+ disable_web_page_preview=True)
+ except Exception as e_edit:
+ logging.warning(
+ f"Could not edit trial result message: {e_edit}. Sending new one.")
+
+ if callback.message and hasattr(callback.message,
+ 'chat') and callback.message.chat:
+ await callback.message.chat.send_message(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=get_main_menu_inline_keyboard(
@@ -152,13 +166,11 @@ async def confirm_activate_trial_handler(
@router.callback_query(F.data == "main_action:cancel_trial")
async def cancel_trial_activation(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
- subscription_service: SubscriptionService):
- show_trial_button_on_back = False
- if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
- callback.from_user.id):
- show_trial_button_on_back = True
+ subscription_service: SubscriptionService,
+ session: AsyncSession):
await send_main_menu(callback,
settings,
i18n_data,
- show_trial_button_flag=show_trial_button_on_back,
+ subscription_service,
+ session,
is_edit=True)
diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py
index 34871b6..55fecf0 100644
--- a/bot/keyboards/inline/admin_keyboards.py
+++ b/bot/keyboards/inline/admin_keyboards.py
@@ -4,6 +4,8 @@ from typing import Optional, List, Any
import math
from config.settings import Settings
+from bot.middlewares.i18n import JsonI18n
+from db.models import User
def get_admin_panel_keyboard(i18n_instance, lang: str,
@@ -79,41 +81,62 @@ def get_logs_pagination_keyboard(
return builder.as_markup()
-def get_banned_users_keyboard(users: List[Any], current_page: int,
- total_users_banned: int, i18n_instance,
+def get_banned_users_keyboard(banned_users: List[User], current_page: int,
+ total_banned: int, i18n_instance: JsonI18n,
lang: str,
settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
page_size = settings.LOGS_PAGE_SIZE
- total_pages = math.ceil(total_users_banned /
- page_size) if page_size > 0 else 1
- if total_users_banned == 0: total_pages = 1
- for user_row in users:
- user_display = user_row['first_name'] or f"ID: {user_row['user_id']}"
- if user_row['username']: user_display += f" (@{user_row['username']})"
- else:
- user_display = f"ID: {user_row['user_id']}" if not user_row[
- 'first_name'] else user_display
- builder.button(text=user_display,
- callback_data=
- f"admin_user_card:{user_row['user_id']}:{current_page}")
- pagination_row = []
- if current_page > 0:
- pagination_row.append(
+
+ if not banned_users and total_banned == 0:
+ pass
+
+ for user_row in banned_users:
+
+ user_display_parts = []
+ if user_row.first_name:
+ user_display_parts.append(user_row.first_name)
+ if user_row.username:
+ user_display_parts.append(f"(@{user_row.username})")
+ if not user_display_parts:
+ user_display_parts.append(f"ID: {user_row.user_id}")
+
+ user_display = " ".join(user_display_parts).strip()
+
+ button_text = _("admin_banned_user_button_text",
+ user_display=user_display,
+ user_id=user_row.user_id)
+ builder.row(
InlineKeyboardButton(
- text="⬅️ " + _("prev_page_button", default="Prev"),
- callback_data=f"admin_action:view_banned:{current_page - 1}"))
- if (current_page + 1) * page_size < total_users_banned:
- pagination_row.append(
- InlineKeyboardButton(
- text=_("next_page_button", default="Next") + " ➡️",
- callback_data=f"admin_action:view_banned:{current_page + 1}"))
- if pagination_row: builder.row(*pagination_row)
+ text=button_text,
+ callback_data=
+ f"admin_user_card:{user_row.user_id}:{current_page}"))
+
+ if total_banned > page_size:
+ total_pages = math.ceil(total_banned / page_size)
+ pagination_buttons = []
+ if current_page > 0:
+ pagination_buttons.append(
+ InlineKeyboardButton(
+ text=_("prev_page_button"),
+ callback_data=f"admin_action:view_banned:{current_page - 1}"
+ ))
+ pagination_buttons.append(
+ InlineKeyboardButton(text=f"{current_page + 1}/{total_pages}",
+ callback_data="stub_page_display"))
+ if current_page < total_pages - 1:
+ pagination_buttons.append(
+ InlineKeyboardButton(
+ text=_("next_page_button"),
+ callback_data=f"admin_action:view_banned:{current_page + 1}"
+ ))
+ if pagination_buttons:
+ builder.row(*pagination_buttons)
+
builder.row(
- InlineKeyboardButton(text=_(key="back_to_admin_panel_button"),
+ InlineKeyboardButton(text=_("back_to_admin_panel_button"),
callback_data="admin_action:main"))
- builder.adjust(1)
return builder.as_markup()
diff --git a/bot/main_bot.py b/bot/main_bot.py
index a171cf2..99708bb 100644
--- a/bot/main_bot.py
+++ b/bot/main_bot.py
@@ -1,7 +1,9 @@
import logging
import asyncio
+from typing import Callable, Dict, Any, Awaitable, Optional
-from aiogram import Bot, Dispatcher, Router, F
+from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
+from aiogram.types import Update
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command
from aiogram.client.default import DefaultBotProperties
@@ -9,75 +11,120 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
from aiogram.fsm.storage.memory import MemoryStorage
from aiohttp import web
from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from sqlalchemy.orm import sessionmaker
-from config.settings import Settings, get_settings
-from .middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
-from .middlewares.ban_check_middleware import BanCheckMiddleware
-from .middlewares.action_logger_middleware import ActionLoggerMiddleware
+from config.settings import Settings
-from .handlers.user import user_router_aggregate
-from .handlers.user import payment as user_payment_webhook_module
+from db.database_setup import init_db_connection
-from .handlers.admin import admin_router_aggregate
-from .filters.admin_filter import AdminFilter
+from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
+from bot.middlewares.ban_check_middleware import BanCheckMiddleware
+from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
-from db.database import get_db_connection_manager
+from bot.handlers.user import user_router_aggregate
+from bot.handlers.admin import admin_router_aggregate
+from bot.filters.admin_filter import AdminFilter
-from .services.notification_service import schedule_subscription_notifications
-from .services.payment_service import YooKassaService
-from .services.panel_api_service import PanelApiService
-from .services.subscription_service import SubscriptionService
-from .services.referral_service import ReferralService
-from .services.promo_code_service import PromoCodeService
+from bot.services.notification_service import schedule_subscription_notifications
+from bot.services.payment_service import YooKassaService
+from bot.services.panel_api_service import PanelApiService
+from bot.services.subscription_service import SubscriptionService
+from bot.services.referral_service import ReferralService
+from bot.services.promo_code_service import PromoCodeService
+
+from bot.handlers.user import payment as user_payment_webhook_module
+
+
+class DBSessionMiddleware(BaseMiddleware):
+
+ def __init__(self, async_session_factory: sessionmaker):
+ super().__init__()
+ self.async_session_factory = async_session_factory
+
+ async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
+ Awaitable[Any]], event: Update,
+ data: Dict[str, Any]) -> Any:
+ if self.async_session_factory is None:
+ logging.critical(
+ "DBSessionMiddleware: async_session_factory is None!")
+ raise RuntimeError(
+ "async_session_factory not provided to DBSessionMiddleware")
+
+ async with self.async_session_factory() as session:
+ data["session"] = session
+ try:
+ result = await handler(event, data)
+
+ await session.commit()
+ return result
+ except Exception:
+ await session.rollback()
+ logging.error(
+ "DBSessionMiddleware: Exception caused rollback.",
+ exc_info=True)
+ raise
async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(user_router_aggregate)
- admin_filtered_router_wrapper = Router(
- name="admin_filtered_router_wrapper")
+
+ admin_main_router = Router(name="admin_main_filtered_router")
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
- admin_filtered_router_wrapper.message.filter(admin_filter_instance)
- admin_filtered_router_wrapper.callback_query.filter(admin_filter_instance)
- admin_filtered_router_wrapper.include_router(admin_router_aggregate)
- dp.include_router(admin_filtered_router_wrapper)
+
+ admin_main_router.message.filter(admin_filter_instance)
+ admin_main_router.callback_query.filter(admin_filter_instance)
+
+ admin_main_router.include_router(admin_router_aggregate)
+
+ dp.include_router(admin_main_router)
logging.info("All application routers registered.")
async def on_startup_configured(dispatcher: Dispatcher):
-
bot: Bot = dispatcher["bot_instance"]
settings: Settings = dispatcher["settings"]
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
+ panel_service: PanelApiService = dispatcher["panel_service"]
+
+ async_session_factory: sessionmaker = dispatcher["async_session_factory"]
+
logging.info("STARTUP: on_startup_configured executing...")
scheduler = AsyncIOScheduler(timezone="UTC")
try:
+
await schedule_subscription_notifications(bot, settings, i18n_instance,
- scheduler)
+ scheduler, panel_service,
+ async_session_factory)
scheduler.start()
dispatcher["scheduler"] = scheduler
logging.info("STARTUP: APScheduler started.")
except Exception as e:
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
exc_info=True)
+
telegram_webhook_url_to_set = getattr(settings,
'TELEGRAM_WEBHOOK_BASE_URL', None)
if telegram_webhook_url_to_set:
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
logging.error(
- f"CRITICAL: Bot token in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}')."
+ f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
+ "This is a major security vulnerability. Webhook will NOT be set."
)
- full_telegram_webhook_url = "ERROR_URL"
+ full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
else:
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
+
logging.info(
- f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url}"
+ f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
)
- try:
- current_webhook_info_before = await bot.get_webhook_info()
- logging.info(
- f"STARTUP: Current webhook info BEFORE: {current_webhook_info_before.model_dump_json(exclude_none=True, indent=2)}"
- )
- if full_telegram_webhook_url != "ERROR_URL":
+
+ if full_telegram_webhook_url != "ERROR_URL_TOKEN_DETECTED":
+ try:
+ current_webhook_info = await bot.get_webhook_info()
+ logging.info(
+ f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
+ )
+
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
drop_pending_updates=True,
@@ -90,46 +137,64 @@ async def on_startup_configured(dispatcher: Dispatcher):
logging.error(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False)."
)
+
new_webhook_info = await bot.get_webhook_info()
logging.info(
- f"STARTUP: Webhook info AFTER: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
+ f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}"
)
if not new_webhook_info.url:
logging.error(
- "STARTUP: CRITICAL - Webhook URL EMPTY after set attempt."
+ "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity."
)
- else:
+
+ except Exception as e_setwebhook:
logging.error(
- "STARTUP: Skipped setting webhook due to URL config error."
- )
- except Exception as e_setwebhook:
+ f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
+ exc_info=True)
+ else:
logging.error(
- f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
- exc_info=True)
+ "STARTUP: Skipped setting Telegram webhook due to security or configuration error."
+ )
else:
logging.info(
- "STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook."
+ "STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)."
)
await bot.delete_webhook(drop_pending_updates=True)
+
logging.info("STARTUP: Bot on_startup_configured completed.")
async def on_shutdown_configured(dispatcher: Dispatcher):
-
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
- scheduler: AsyncIOScheduler = dispatcher.get("scheduler")
+
+ scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
if scheduler and scheduler.running:
try:
scheduler.shutdown(wait=False)
logging.info("SHUTDOWN: APScheduler shut down.")
except Exception as e:
- logging.error(f"SHUTDOWN: Error APScheduler: {e}", exc_info=True)
- panel_service: PanelApiService = dispatcher.get("panel_service")
- if panel_service and panel_service._session and not panel_service._session.closed:
+ logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
+ exc_info=True)
+
+ panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
+ if panel_service and hasattr(panel_service, 'close_session'):
await panel_service.close_session()
- bot: Bot = dispatcher.get("bot_instance")
- if bot and bot.session and not bot.session.closed:
- await bot.session.close()
+ logging.info("Panel API service session closed on shutdown.")
+
+ bot: Bot = dispatcher["bot_instance"]
+ if bot and bot.session:
+ try:
+ await bot.session.close()
+ logging.info("SHUTDOWN: Aiogram Bot session closed.")
+ except Exception as e:
+ logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
+
+ from db.database_setup import async_engine as global_async_engine
+ if global_async_engine:
+ logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
+ await global_async_engine.dispose()
+ logging.info("SHUTDOWN: SQLAlchemy engine disposed.")
+
logging.info("SHUTDOWN: Bot on_shutdown_configured completed.")
@@ -138,49 +203,52 @@ async def run_bot(settings_param: Settings):
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings_param.BOT_TOKEN, default=default_props)
+ local_async_session_factory = init_db_connection(settings_param)
+ if local_async_session_factory is None:
+ logging.critical(
+ "Failed to initialize database connection and session factory. Exiting."
+ )
+ return
+
dp = Dispatcher(storage=storage, settings=settings_param, bot_instance=bot)
actual_bot_username = "your_bot_username"
try:
bot_info = await bot.get_me()
actual_bot_username = bot_info.username
- logging.info(f"Bot username: @{actual_bot_username}")
+ logging.info(f"Bot username resolved: @{actual_bot_username}")
except Exception as e:
logging.error(
- f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}"
+ f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
)
- default_lang = settings_param.DEFAULT_LANGUAGE
- i18n_instance = get_i18n_instance(path="locales", default=default_lang)
+ i18n_instance = get_i18n_instance(path="locales",
+ default=settings_param.DEFAULT_LANGUAGE)
yookassa_service = YooKassaService(
shop_id=settings_param.YOOKASSA_SHOP_ID,
secret_key=settings_param.YOOKASSA_SECRET_KEY,
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
- bot_username_for_default=actual_bot_username,
+ bot_username_for_default_return=actual_bot_username,
settings_obj=settings_param)
-
panel_service = PanelApiService(settings_param)
- subscription_service = SubscriptionService(get_db_connection_manager,
- settings_param, panel_service)
- referral_service = ReferralService(get_db_connection_manager,
- settings_param, subscription_service,
+
+ subscription_service = SubscriptionService(settings_param, panel_service)
+ referral_service = ReferralService(settings_param, subscription_service,
bot, i18n_instance)
- promo_code_service = PromoCodeService(get_db_connection_manager,
- settings_param, subscription_service,
+ promo_code_service = PromoCodeService(settings_param, subscription_service,
bot, i18n_instance)
dp["i18n_instance"] = i18n_instance
- dp["i18n_data"] = {
- "i18n_instance": i18n_instance,
- "current_language": default_lang
- }
dp["yookassa_service"] = yookassa_service
dp["panel_service"] = panel_service
dp["subscription_service"] = subscription_service
dp["referral_service"] = referral_service
dp["promo_code_service"] = promo_code_service
+ dp["async_session_factory"] = local_async_session_factory
+ dp.update.outer_middleware(
+ DBSessionMiddleware(local_async_session_factory))
dp.update.outer_middleware(
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
dp.update.outer_middleware(
@@ -189,106 +257,138 @@ async def run_bot(settings_param: Settings):
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
dp.startup.register(on_startup_configured)
- dp.shutdown.register(on_shutdown_configured)
+ dp.shutdown.register(lambda: on_shutdown_configured(dp))
+
await register_all_routers(dp, settings_param)
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
None)
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
None)
- logging.info(f"--- Determining Run Mode ---")
- logging.info(f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}'")
- logging.info(f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}'")
- should_run_aiohttp = bool(
- yk_webhook_base
- and settings_param.yookassa_webhook_path) or bool(tg_webhook_base)
- telegram_uses_webhook = bool(tg_webhook_base)
- telegram_should_poll = not telegram_uses_webhook
- logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp}")
- logging.info(f"Decision: Telegram uses webhook: {telegram_uses_webhook}")
- logging.info(f"Decision: Telegram should poll: {telegram_should_poll}")
- logging.info(f"--- End Run Mode Decision ---")
+
+ should_run_aiohttp_server = bool(tg_webhook_base) or (
+ bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path))
+
+ telegram_uses_webhook_mode = bool(tg_webhook_base)
+ run_telegram_polling = not telegram_uses_webhook_mode
+
+ logging.info(f"--- Bot Run Mode Decision ---")
+ logging.info(
+ f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
+ )
+ logging.info(
+ f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'"
+ )
+ logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
+ logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
+ logging.info(f"--- End Bot Run Mode Decision ---")
+
web_app_runner = None
- main_tasks_to_await = []
- if should_run_aiohttp:
+ main_tasks = []
+
+ if should_run_aiohttp_server:
app = web.Application()
app['bot'] = bot
app['dp'] = dp
app['settings'] = settings_param
app['i18n'] = i18n_instance
+ app['async_session_factory'] = local_async_session_factory
+
app['yookassa_service'] = yookassa_service
- app['panel_service'] = panel_service
app['subscription_service'] = subscription_service
app['referral_service'] = referral_service
- app['promo_code_service'] = promo_code_service
+ app['panel_service'] = panel_service
+
setup_application(app, dp, bot=bot)
- logging.info(
- "AIOHTTP app context populated and dispatcher lifecycle linked.")
- if telegram_uses_webhook:
+
+ if telegram_uses_webhook_mode:
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
+ if not telegram_webhook_path.startswith('/'):
+ telegram_webhook_path = '/' + telegram_webhook_path
app.router.add_post(telegram_webhook_path,
SimpleRequestHandler(dispatcher=dp, bot=bot))
- logging.info(f"Telegram webhook route: {telegram_webhook_path}.")
- if yk_webhook_base and settings_param.yookassa_webhook_path:
- app.router.add_post(
- settings_param.yookassa_webhook_path,
- user_payment_webhook_module.yookassa_webhook_route)
logging.info(
- f"YK webhook route: {settings_param.yookassa_webhook_path}")
+ f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
+ )
+
+ if yk_webhook_base and settings_param.yookassa_webhook_path:
+ yk_path = settings_param.yookassa_webhook_path
+ if not yk_path or not isinstance(yk_path, str):
+ logging.error(
+ f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
+ )
+ elif not yk_path.startswith('/'):
+ logging.error(
+ f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
+ )
+ else:
+ app.router.add_post(
+ yk_path,
+ user_payment_webhook_module.yookassa_webhook_route)
+ logging.info(
+ f"YooKassa webhook route configured at: [POST] {yk_path}")
+
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
site = web.TCPSite(web_app_runner,
host=settings_param.WEB_SERVER_HOST,
port=settings_param.WEB_SERVER_PORT)
- async def web_server_task_wrapper():
+ async def web_server_task():
await site.start()
logging.info(
- f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}."
+ f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
)
await asyncio.Event().wait(
- ) if not telegram_should_poll else await asyncio.sleep(31536000)
+ ) if not run_telegram_polling else await asyncio.sleep(31536000)
- main_tasks_to_await.append(
- asyncio.create_task(web_server_task_wrapper(),
- name="AIOHTTPServerWrapperTask"))
- if telegram_should_poll:
- logging.info("TG polling task created.")
- main_tasks_to_await.append(
+ main_tasks.append(
+ asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
+
+ if run_telegram_polling:
+ logging.info("Starting bot in Telegram Polling mode...")
+ main_tasks.append(
asyncio.create_task(dp.start_polling(
bot, allowed_updates=dp.resolve_used_update_types()),
name="TelegramPollingTask"))
- if not main_tasks_to_await:
- logging.error("Bot not configured for any mode.")
+
+ if not main_tasks:
+ logging.error(
+ "Bot is not configured for any run mode (neither Webhook nor Polling). Exiting."
+ )
await dp.emit_shutdown()
return
+
logging.info(
- f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}"
+ f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}"
)
+
try:
- if main_tasks_to_await: await asyncio.gather(*main_tasks_to_await)
+ await asyncio.gather(*main_tasks)
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
- logging.info(f"Main loop interrupted: {type(e).__name__}")
+ logging.info(
+ f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
finally:
- logging.info("Initiating final shutdown sequence...")
- for task in main_tasks_to_await:
- if task and not task.done(): task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- logging.info(f"Task '{task.get_name()}' cancelled.")
- except Exception as e_cancel:
- logging.error(
- f"Error cancelling task '{task.get_name()}': {e_cancel}",
- exc_info=True)
+ logging.info("Initiating final bot shutdown sequence...")
+ for task in main_tasks:
+ if task and not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ logging.info(
+ f"Task '{task.get_name()}' was cancelled successfully."
+ )
+ except Exception as e_task_cancel:
+ logging.error(
+ f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
+ exc_info=True)
+
if web_app_runner:
await web_app_runner.cleanup()
logging.info("AIOHTTP AppRunner cleaned up.")
- if not (telegram_should_poll and len(main_tasks_to_await) == 1
- and any(t.get_name() == "TelegramPollingTask"
- for t in main_tasks_to_await if t and not t.done())):
- logging.info(
- "Explicitly calling dp.emit_shutdown() in run_bot finally.")
- await dp.emit_shutdown()
- if bot.session and not bot.session.closed: await bot.session.close()
+
+ await dp.emit_shutdown()
+ logging.info("Dispatcher shutdown sequence emitted.")
+
logging.info("Bot run_bot function finished.")
diff --git a/bot/middlewares/action_logger_middleware.py b/bot/middlewares/action_logger_middleware.py
index ee75b2e..2261df8 100644
--- a/bot/middlewares/action_logger_middleware.py
+++ b/bot/middlewares/action_logger_middleware.py
@@ -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
diff --git a/bot/middlewares/ban_check_middleware.py b/bot/middlewares/ban_check_middleware.py
index 625e05f..dc40f5c 100644
--- a/bot/middlewares/ban_check_middleware.py
+++ b/bot/middlewares/ban_check_middleware.py
@@ -1,15 +1,16 @@
import logging
-from typing import Callable, Dict, Any, Awaitable, Union, Optional
+from typing import Callable, Dict, Any, Awaitable, Optional, Union
from aiogram import BaseMiddleware, Bot
-from aiogram.types import Message, CallbackQuery, User, InlineKeyboardMarkup, Update
-from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.types import Message, CallbackQuery, User, Update, InlineKeyboardMarkup
+from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.exceptions import TelegramAPIError, TelegramForbiddenError, TelegramBadRequest, AiogramError
from config.settings import Settings
-from db.database import get_user
-from bot.middlewares.i18n import JsonI18n
-from bot.keyboards.inline.user_keyboards import get_user_banned_keyboard
+from db.dal import user_dal
+
+from .i18n import JsonI18n
+from ..keyboards.inline.user_keyboards import get_user_banned_keyboard
class BanCheckMiddleware(BaseMiddleware):
@@ -22,191 +23,88 @@ class BanCheckMiddleware(BaseMiddleware):
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
Awaitable[Any]], event: Update,
data: Dict[str, Any]) -> Any:
+ session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
- if not event_user: return await handler(event, data)
+ bot_instance: Bot = data["bot"]
+
+ if not event_user:
+ return await handler(event, data)
if event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
try:
- db_user_data = await get_user(user_id=event_user.id)
+ db_user_model = await user_dal.get_user_by_id(
+ session, event_user.id)
except Exception as e_db:
logging.error(
f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}",
exc_info=True)
return await handler(event, data)
- if db_user_data and db_user_data['is_banned'] == 1:
+ if db_user_model and db_user_model.is_banned:
logging.info(
- f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification."
+ f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access."
)
- ban_message_text = "You are blocked. Please contact support."
- keyboard: Optional[InlineKeyboardMarkup] = None
- current_lang = self.settings.DEFAULT_LANGUAGE
- i18n_to_use: Optional[JsonI18n] = None
- actual_event_object: Optional[Union[Message, CallbackQuery]] = None
+ i18n_data_from_event = data.get("i18n_data", {})
+ current_lang = i18n_data_from_event.get(
+ "current_language", self.settings.DEFAULT_LANGUAGE)
+ i18n_to_use: Optional[JsonI18n] = i18n_data_from_event.get(
+ "i18n_instance", self.i18n_main_instance)
- if event.message:
- actual_event_object = event.message
+ ban_message_text = "You are banned. Please contact support."
+ keyboard: Optional[InlineKeyboardMarkup] = None
+
+ if i18n_to_use:
+ _ = lambda k, **kw: i18n_to_use.gettext(current_lang, k, **kw)
+ ban_message_text = _("user_is_banned")
+ keyboard = get_user_banned_keyboard(self.settings.SUPPORT_LINK,
+ current_lang, i18n_to_use)
+ elif self.settings.SUPPORT_LINK:
+ from aiogram.utils.keyboard import InlineKeyboardBuilder
+ builder = InlineKeyboardBuilder()
+ builder.button(text="Support", url=self.settings.SUPPORT_LINK)
+ keyboard = builder.as_markup()
+
+ actual_event_object: Optional[Union[Message, CallbackQuery]] = None
+ if event.message: actual_event_object = event.message
elif event.callback_query:
actual_event_object = event.callback_query
- if not actual_event_object:
- logging.warning(
- f"BanCheck: Could not determine specific event type (Message/CallbackQuery) for banned user {event_user.id} from Update object. Update type: {event.type}"
- )
-
- try:
- bot_instance: Bot = data["bot"]
- await bot_instance.send_message(event_user.id,
- ban_message_text)
- except Exception as e_direct_send:
- logging.error(
- f"BanCheck: Failed to send direct ban message to {event_user.id}: {e_direct_send}"
- )
- return
-
try:
- logging.debug(
- "BanCheck: [A] Inside main try block for banned user notification."
- )
- i18n_data_from_event = data.get("i18n_data", {})
- current_lang = i18n_data_from_event.get(
- "current_language", self.settings.DEFAULT_LANGUAGE)
- i18n_to_use = i18n_data_from_event.get("i18n_instance")
- logging.debug(
- f"BanCheck: [B] i18n_instance from event_data: {type(i18n_to_use)}. Current lang: {current_lang}"
- )
-
- if not i18n_to_use:
- i18n_to_use = self.i18n_main_instance
- logging.warning(
- f"BanCheck: [B_fallback] Using fallback i18n instance for banned user {event_user.id}. Type: {type(i18n_to_use)}"
- )
-
- if i18n_to_use:
- _ = lambda k, **kw: i18n_to_use.gettext(
- current_lang, k, **kw)
- logging.debug(
- "BanCheck: [D] Attempting to get 'user_is_banned' text."
- )
- ban_message_text = _("user_is_banned")
- logging.debug(
- f"BanCheck: [E] Ban message text: '{ban_message_text}'"
- )
- if self.settings.SUPPORT_LINK:
- logging.debug(
- "BanCheck: [F] Support link found. Attempting to get user_banned_keyboard."
- )
- keyboard = get_user_banned_keyboard(
- self.settings.SUPPORT_LINK, current_lang,
- i18n_to_use)
- logging.debug(
- f"BanCheck: [G] Keyboard created: {keyboard is not None}"
- )
- else:
- logging.debug(
- "BanCheck: [F_alt] No support link configured.")
- else:
- logging.error(
- f"BanCheck: [CRITICAL] No i18n instance for user {event_user.id}. Using hardcoded text."
- )
- if self.settings.SUPPORT_LINK:
- kb_temp = InlineKeyboardBuilder()
- kb_temp.button(text="Support",
- url=self.settings.SUPPORT_LINK)
- keyboard = kb_temp.as_markup()
- logging.debug(
- "BanCheck: [G_alt] Fallback keyboard created due to no i18n."
- )
-
- logging.debug(
- f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}"
- )
-
if isinstance(actual_event_object, Message):
- logging.debug(
- f"BanCheck: [I_Msg] Attempting actual_event_object.answer for Message to user {event_user.id}"
- )
await actual_event_object.answer(ban_message_text,
reply_markup=keyboard)
- logging.info(
- f"BanCheck: [J_Msg] Ban notification 'actual_event_object.answer' attempted for user {event_user.id} (Message)."
- )
elif isinstance(actual_event_object, CallbackQuery):
- logging.debug(
- f"BanCheck: [I_CB] Attempting actual_event_object.answer (alert) for CallbackQuery to user {event_user.id}"
- )
await actual_event_object.answer(ban_message_text,
show_alert=True)
- logging.info(
- f"BanCheck: [J_CB] Ban alert 'actual_event_object.answer' attempted for user {event_user.id} (CallbackQuery)."
- )
-
- target_message_obj = actual_event_object.message
- if target_message_obj:
- target_chat_id = target_message_obj.chat.id
+ if actual_event_object.message:
try:
- logging.debug(
- f"BanCheck: [K_CB_Edit] Attempting target_message_obj.edit_text for user {event_user.id}"
- )
- await target_message_obj.edit_text(
+ await actual_event_object.message.edit_text(
ban_message_text, reply_markup=keyboard)
- logging.info(
- f"BanCheck: [L_CB_Edit] Ban msg 'target_message_obj.edit_text' attempted for user {event_user.id} (Callback)."
- )
- except Exception as e_edit:
- logging.warning(
- f"BanCheck: [M_CB_EditFail] Failed to edit message for banned user {event_user.id}: {type(e_edit).__name__} - {e_edit}. Sending new message."
- )
- await actual_event_object.bot.send_message(
- target_chat_id,
+ except (TelegramAPIError, AiogramError):
+ await bot_instance.send_message(
+ actual_event_object.from_user.id,
ban_message_text,
reply_markup=keyboard)
- logging.info(
- f"BanCheck: [N_CB_NewMsg] Ban msg 'actual_event_object.bot.send_message' (after edit fail) attempted for user {event_user.id} (Callback)."
- )
else:
- logging.warning(
- f"BanCheck: [K_CB_NoMsg] CallbackQuery from {event_user.id} has no .message attribute. Sending new message directly."
- )
- await actual_event_object.bot.send_message(
+ await bot_instance.send_message(
actual_event_object.from_user.id,
ban_message_text,
reply_markup=keyboard)
- logging.info(
- f"BanCheck: [L_CB_NoMsg_NewMsg] Ban msg 'actual_event_object.bot.send_message' (no .message) attempted for user {event_user.id} (Callback)."
- )
else:
- logging.error(
- f"BanCheck: [UNHANDLED_EVENT_TYPE_INTERNAL] actual_event_object type {type(actual_event_object)} was not Message or CallbackQuery."
- )
-
- except TelegramForbiddenError as e_forbidden:
+ await bot_instance.send_message(event_user.id,
+ ban_message_text,
+ reply_markup=keyboard)
+ logging.info(f"Ban notification sent to user {event_user.id}.")
+ except TelegramForbiddenError:
logging.warning(
- f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}"
- )
- except TelegramBadRequest as e_bad_req:
+ f"BanCheck: Bot is blocked by user {event_user.id}.")
+ except Exception as e_send:
logging.error(
- f"BanCheck: TelegramBadRequest sending ban msg to {event_user.id}: {e_bad_req}",
- exc_info=True)
- except TelegramAPIError as e_api:
- logging.error(
- f"BanCheck: TelegramAPIError sending ban msg to {event_user.id}: {e_api}",
- exc_info=True)
- except AiogramError as e_aio:
- logging.error(
- f"BanCheck: AiogramError sending ban msg to {event_user.id}: {e_aio}",
- exc_info=True)
- except Exception as e_general:
- logging.error(
- f"BanCheck: Generic failure preparing or sending ban notification to user {event_user.id}: {e_general}",
+ f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}",
exc_info=True)
- logging.debug(
- f"BanCheck: [Z] End of ban processing for user {event_user.id}. Returning to stop further handlers."
- )
return
-
return await handler(event, data)
diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py
index 116f95e..80bb2b8 100644
--- a/bot/middlewares/i18n.py
+++ b/bot/middlewares/i18n.py
@@ -1,13 +1,13 @@
import logging
import json
import os
-import aiosqlite
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
-from aiogram.types import TelegramObject, User, Update
+from aiogram.types import User, Update
+from sqlalchemy.ext.asyncio import AsyncSession
-from db.database import get_user, get_db_connection_manager, _setup_db_connection
+from db.dal import user_dal
from config.settings import Settings
@@ -25,7 +25,8 @@ class JsonI18n:
def _load_locales(self):
if not os.path.isdir(self.path):
- logging.error(f"Locales path not found: {self.path}")
+ logging.error(
+ f"Locales path not found or not a directory: {self.path}")
return
for item in os.listdir(self.path):
if item.endswith(".json"):
@@ -34,52 +35,73 @@ class JsonI18n:
try:
with open(file_path, "r", encoding="utf-8") as f:
self.locales_data[lang_code] = json.load(f)
- except Exception as e:
+ except json.JSONDecodeError as e_json_load:
logging.error(
- f"Error loading locale {lang_code} from {file_path}: {e}"
+ f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}"
)
+ except Exception as e_load:
+ logging.error(
+ f"Error loading locale {lang_code} from {file_path}: {e_load}",
+ exc_info=True)
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
- effective_lang_code = lang_code
- if not effective_lang_code or effective_lang_code not in self.locales_data:
- effective_lang_code = self.default_lang
+ effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
+
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
logging.warning(
- f"No language data for '{effective_lang_code}' (default: '{self.default_lang}'). Key '{key}'."
+ f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
)
return key.format(**kwargs) if kwargs else key
+
text = lang_data.get(key)
if text is None:
if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(
self.default_lang, {})
text = default_lang_data.get(key)
- if text is None: return key.format(**kwargs) if kwargs else key
+
+ if text is None:
+ logging.warning(
+ f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key."
+ )
+ return key.format(**kwargs) if kwargs else key
try:
return text.format(**kwargs) if kwargs else text
- except KeyError as e:
+ except KeyError as e_format:
logging.warning(
- f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'"
+ f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'"
)
return text
- except Exception as e:
+ except Exception as e_general_format:
logging.error(
- f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'"
- )
+ f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'",
+ exc_info=True)
return text
-_i18n_instance: Optional[JsonI18n] = None
+_i18n_instance_singleton: Optional[JsonI18n] = None
def get_i18n_instance(path: str = "locales",
default: str = "en",
domain: str = "bot") -> JsonI18n:
- global _i18n_instance
- if _i18n_instance is None:
- _i18n_instance = JsonI18n(path=path, default=default, domain=domain)
- return _i18n_instance
+ global _i18n_instance_singleton
+ if _i18n_instance_singleton is None:
+
+ if not os.path.exists(path) or not os.path.isdir(path):
+ logging.error(
+ f"CRITICAL: Locales directory '{path}' not found. i18n will not work correctly."
+ )
+
+ _i18n_instance_singleton = JsonI18n(path=path,
+ default=default,
+ domain=domain)
+ else:
+ _i18n_instance_singleton = JsonI18n(path=path,
+ default=default,
+ domain=domain)
+ return _i18n_instance_singleton
class I18nMiddleware(BaseMiddleware):
@@ -92,65 +114,40 @@ class I18nMiddleware(BaseMiddleware):
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
Awaitable[Any]], event: Update,
data: Dict[str, Any]) -> Any:
+ session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
current_language = self.i18n.default_lang
if event_user:
- logging.debug(
- f"I18nMiddleware: Processing for user {event_user.id}")
-
try:
-
- user_db_data = await get_user(event_user.id)
- if user_db_data and user_db_data[
- 'language_code'] and user_db_data[
- 'language_code'] in self.i18n.locales_data:
- current_language = user_db_data['language_code']
- logging.debug(
- f"I18nMiddleware: User {event_user.id} language loaded from DB: {current_language}"
- )
- else:
-
- if event_user.language_code:
- lang_prefix = event_user.language_code.split('-')[0]
- if lang_prefix in self.i18n.locales_data:
- current_language = lang_prefix
- logging.debug(
- f"I18nMiddleware: User {event_user.id} language set from Telegram client (prefix): {current_language}"
- )
- elif event_user.language_code in self.i18n.locales_data:
- current_language = event_user.language_code
- logging.debug(
- f"I18nMiddleware: User {event_user.id} language set from Telegram client (full): {current_language}"
- )
- else:
-
- logging.debug(
- f"I18nMiddleware: User {event_user.id} Telegram client language '{event_user.language_code}' not supported. Using default: {current_language}"
- )
- else:
- logging.debug(
- f"I18nMiddleware: User {event_user.id} has no language_code from Telegram. Using default: {current_language}"
- )
- except Exception as e_db_lang:
-
- logging.error(
- f"I18nMiddleware: Error fetching user language from DB for user {event_user.id}: {e_db_lang}. Falling back."
- )
- if event_user.language_code:
- lang_prefix = event_user.language_code.split('-')[0]
+ user_db_model = await user_dal.get_user_by_id(
+ session, event_user.id)
+ if user_db_model and user_db_model.language_code and user_db_model.language_code in self.i18n.locales_data:
+ current_language = user_db_model.language_code
+ elif event_user.language_code:
+ lang_prefix = event_user.language_code.split(
+ '-')[0].lower()
if lang_prefix in self.i18n.locales_data:
current_language = lang_prefix
- elif event_user.language_code in self.i18n.locales_data:
- current_language = event_user.language_code
+ elif event_user.language_code.lower(
+ ) in self.i18n.locales_data:
+ current_language = event_user.language_code.lower()
+ except Exception as e_db_lang:
+ logging.error(
+ f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.",
+ exc_info=True)
+ if event_user.language_code:
+ lang_prefix = event_user.language_code.split(
+ '-')[0].lower()
+ if lang_prefix in self.i18n.locales_data:
+ current_language = lang_prefix
+ elif event_user.language_code.lower(
+ ) in self.i18n.locales_data:
+ current_language = event_user.language_code.lower()
data["i18n_data"] = {
"i18n_instance": self.i18n,
"current_language": current_language
}
- logging.debug(
- f"I18nMiddleware: Final current_language for event: {current_language}"
- )
-
return await handler(event, data)
diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py
index cb59069..9faf8dc 100644
--- a/bot/services/notification_service.py
+++ b/bot/services/notification_service.py
@@ -2,95 +2,115 @@ import logging
import asyncio
from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from datetime import datetime
+from datetime import datetime, timezone
from config.settings import Settings
-from .subscription_service import SubscriptionService
-from db.database import get_db_connection_manager
+from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
-from .panel_api_service import PanelApiService
+from bot.services.panel_api_service import PanelApiService
+from bot.services.subscription_service import SubscriptionService
async def send_expiration_warnings(bot: Bot, settings: Settings,
i18n: JsonI18n,
- panel_service: PanelApiService):
- """
- Sends expiration warnings to users whose subscriptions are ending soon.
- This function is called by the scheduler.
- """
+ panel_service: PanelApiService,
+ async_session_factory: sessionmaker):
+
logging.info(
- f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}..."
+ f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
)
- sub_service = SubscriptionService(get_db_connection_manager, settings,
- panel_service)
-
- expiring_subs = await sub_service.get_subscriptions_ending_soon(
- settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
-
- if not expiring_subs:
- logging.info("No subscriptions found ending soon for notification.")
+ if async_session_factory is None:
+ logging.error(
+ "NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
+ )
return
- logging.info(
- f"Found {len(expiring_subs)} subscriptions ending soon for notification."
- )
- for sub_info in expiring_subs:
- user_id = sub_info['user_id']
- if not user_id:
- logging.warning(
- f"Skipping notification for subscription without user_id: {sub_info}"
- )
- continue
-
- user_lang = sub_info.get('language_code') if sub_info.get(
- 'language_code') else getattr(settings, 'DEFAULT_LANGUAGE', 'en')
- first_name = sub_info.get('first_name', 'User')
- end_date_str = sub_info['end_date_str']
-
- days_left_float = sub_info.get('days_left')
- days_left_display = 'N/A'
- if days_left_float is not None:
-
- days_left_display = max(0, int(round(days_left_float)))
-
- _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
- message_text = _("subscription_ending_soon_notification",
- user_name=first_name,
- end_date=end_date_str,
- days_left=days_left_display)
+ async with async_session_factory() as session:
try:
- await bot.send_message(user_id, message_text)
- await sub_service.update_last_notification_sent(
- user_id, end_date_str)
+ sub_service = SubscriptionService(settings, panel_service)
+
+ expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
+ session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
+
+ if not expiring_subs_details_list:
+ logging.info(
+ "No subscriptions found ending soon for notification.")
+ return
+
logging.info(
- f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}"
+ f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
)
- except Exception as e:
+ for sub_details in expiring_subs_details_list:
+ user_id = sub_details['user_id']
+ user_lang = sub_details.get('language_code',
+ settings.DEFAULT_LANGUAGE)
+ first_name = sub_details.get('first_name', f"User {user_id}")
+ end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
+ days_left_display = sub_details.get('days_left', "N/A")
+
+ subscription_actual_end_date_obj: Optional[
+ datetime] = sub_details.get(
+ 'subscription_end_date_iso_for_update')
+
+ _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
+ )
+ message_text = _("subscription_ending_soon_notification",
+ user_name=first_name,
+ end_date=end_date_str_for_msg,
+ days_left=days_left_display)
+ try:
+ await bot.send_message(user_id, message_text)
+ logging.info(
+ f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
+ )
+
+ if subscription_actual_end_date_obj:
+ await sub_service.update_last_notification_sent(
+ session, user_id, subscription_actual_end_date_obj)
+ else:
+ logging.warning(
+ f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
+ )
+
+ except Exception as e:
+ logging.error(
+ f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
+ exc_info=True)
+
+ await asyncio.sleep(0.1)
+
+ await session.commit()
+ logging.info(
+ "Finished processing expiration warnings. Session committed.")
+
+ except Exception as e_session:
logging.error(
- f"Failed to send expiration warning to user {user_id}: {e}")
- await asyncio.sleep(0.1)
+ f"Error during send_expiration_warnings session: {e_session}",
+ exc_info=True)
+ await session.rollback()
+ logging.info(
+ "Session rolled back due to error in send_expiration_warnings."
+ )
-async def schedule_subscription_notifications(bot: Bot, settings: Settings,
- i18n: JsonI18n,
- scheduler: AsyncIOScheduler):
- """Schedules the daily job to send expiration warnings."""
+async def schedule_subscription_notifications(
+ bot: Bot, settings: Settings, i18n: JsonI18n,
+ scheduler: AsyncIOScheduler, panel_service: PanelApiService,
+ async_session_factory: sessionmaker):
async def job_wrapper():
- panel_service = PanelApiService(settings)
- try:
- await send_expiration_warnings(bot, settings, i18n, panel_service)
+ try:
+ await send_expiration_warnings(bot, settings, i18n, panel_service,
+ async_session_factory)
except Exception as e:
logging.error(
- f"Error in scheduled job 'send_expiration_warnings': {e}",
+ f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
exc_info=True)
- finally:
- await panel_service.close_session()
try:
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
@@ -98,7 +118,7 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
except (ValueError, TypeError):
logging.warning(
- "SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid. Defaulting to 9:00 UTC."
+ "SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
)
notification_hour = 9
notification_minute = 0
@@ -107,8 +127,9 @@ async def schedule_subscription_notifications(bot: Bot, settings: Settings,
'cron',
hour=notification_hour,
minute=notification_minute,
- name="daily_subscription_expiration_warnings",
- misfire_grace_time=60 * 15)
+ name="daily_subscription_expiration_warnings_v2",
+ misfire_grace_time=60 * 15,
+ replace_existing=True)
logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
)
diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py
index fcdc5b0..8aab103 100644
--- a/bot/services/panel_api_service.py
+++ b/bot/services/panel_api_service.py
@@ -1,9 +1,16 @@
import aiohttp
import logging
+import json
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta, timezone
+import asyncio
+from urllib.parse import urlencode
+
+from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
+from db.dal import panel_sync_dal
+from db.models import PanelSyncStatus
class PanelApiService:
@@ -17,153 +24,304 @@ class PanelApiService:
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
- self._session = aiohttp.ClientSession()
+ timeout = aiohttp.ClientTimeout(total=30)
+ self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close_session(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
- logging.info("Panel API service session closed.")
+ logging.info("Panel API service HTTP session closed.")
async def _prepare_headers(self) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
+ "Accept": "application/json",
"X-Forwarded-Proto": "https",
"X-Forwarded-For": self.default_client_ip,
"X-Real-IP": self.default_client_ip,
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
-
return headers
- async def _request(self, method: str, endpoint: str,
+ async def _request(self,
+ method: str,
+ endpoint: str,
+ log_full_response: bool = False,
**kwargs) -> Optional[Dict[str, Any]]:
if not self.base_url:
- logging.error("Panel API URL not configured.")
+ logging.error(
+ "Panel API URL (PANEL_API_URL) not configured in settings.")
return {
"error": True,
"status_code": 0,
"message": "Panel API URL not configured."
}
- session = await self._get_session()
+ aiohttp_session = await self._get_session()
headers = await self._prepare_headers()
- if "Authorization" not in headers and self.api_key:
- logging.warning(
- f"Authorization header missing for panel endpoint {endpoint} despite API key being set."
- )
+ url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
- url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
- json_payload_for_log = kwargs.get('json') if method in [
+ current_params = kwargs.get("params")
+ url_with_params_for_log = url_for_request
+ if current_params:
+ try:
+ url_with_params_for_log += "?" + urlencode(current_params)
+ except Exception:
+ pass
+
+ json_payload_for_log = kwargs.get('json') if method.upper() in [
"POST", "PATCH", "PUT"
] else None
- log_prefix = f"Panel API {method} {url}"
+ log_prefix = f"Panel API Req: {method.upper()} {url_with_params_for_log}"
if json_payload_for_log:
- log_prefix += f" Payload: {json_payload_for_log}"
-
+ try:
+ payload_str = json.dumps(json_payload_for_log)
+ log_prefix += f" | Payload: {payload_str[:300]}{'...' if len(payload_str) > 300 else ''}"
+ except Exception:
+ log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
try:
- async with session.request(method, url, headers=headers,
- **kwargs) as response:
- if 200 <= response.status < 300:
+ async with aiohttp_session.request(method.upper(),
+ url_for_request,
+ headers=headers,
+ **kwargs) as response:
+ response_status = response.status
+ response_text = await response.text()
+
+ log_suffix = f"| Status: {response_status}"
+
+ if log_full_response or not (200 <= response_status < 300):
try:
- data = await response.json()
- logging.debug(
- f"{log_prefix} - Success ({response.status})")
- return data
- except aiohttp.ContentTypeError:
- logging.debug(
- f"{log_prefix} - Success ({response.status}) with non-JSON response."
+ parsed_json_for_log = json.loads(response_text)
+ pretty_response_text = json.dumps(parsed_json_for_log,
+ indent=2,
+ ensure_ascii=False)
+ logging.info(
+ f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}"
+ )
+ except json.JSONDecodeError:
+ logging.info(
+ f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}"
+ )
+ else:
+ logging.debug(
+ f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}"
+ )
+
+ if 200 <= response_status < 300:
+ try:
+ if 'application/json' in response.headers.get(
+ 'Content-Type', '').lower():
+ data = json.loads(response_text)
+ return data
+ else:
+ return {
+ "status": "success",
+ "code": response_status,
+ "data_text": response_text
+ }
+ except json.JSONDecodeError as e_json_ok:
+ logging.error(
+ f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above."
)
return {
- "status": "success",
- "code": response.status,
- "data_text": await response.text()
+ "status": "success_parse_error",
+ "code": response_status,
+ "data_text": response_text,
+ "parse_error": str(e_json_ok)
}
else:
+ error_details = {
+ "message":
+ f"Request failed with status {response_status}",
+ "raw_response_text": response_text
+ }
try:
- error_json = await response.json()
- logging.error(
- f"{log_prefix} - Failed ({response.status}): {error_json}"
- )
- return {
- "error": True,
- "status_code": response.status,
- "response": error_json,
- "message": error_json.get("message"),
- "errorCode": error_json.get("errorCode")
- }
- except aiohttp.ContentTypeError:
- error_text = await response.text()
- logging.error(
- f"{log_prefix} - Failed ({response.status}): {error_text}"
- )
- return {
- "error": True,
- "status_code": response.status,
- "message": error_text
- }
+ if 'application/json' in response.headers.get(
+ 'Content-Type', '').lower():
+ error_json_data = json.loads(response_text)
+ error_details.update(error_json_data)
+ except json.JSONDecodeError:
+ pass
+ return {
+ "error": True,
+ "status_code": response_status,
+ "details": error_details
+ }
+
+ except aiohttp.ClientConnectorError as e:
+ logging.error(
+ f"Panel API ClientConnectorError to {url_for_request}: {e}")
+ return {
+ "error": True,
+ "status_code": -1,
+ "message": f"Connection error: {str(e)}"
+ }
except aiohttp.ClientError as e:
- logging.error(f"Panel API client request error to {url}: {e}")
- return {"error": True, "status_code": -1, "message": str(e)}
- except Exception as e:
- logging.error(f"Unexpected Panel API request error to {url}: {e}",
- exc_info=True)
+ logging.error(f"Panel API ClientError to {url_for_request}: {e}")
return {
"error": True,
"status_code": -2,
+ "message": f"Client error: {str(e)}"
+ }
+ except asyncio.TimeoutError:
+ logging.error(f"Panel API request to {url_for_request} timed out.")
+ return {
+ "error": True,
+ "status_code": -3,
+ "message": "Request timed out"
+ }
+ except Exception as e:
+ logging.error(
+ f"Unexpected Panel API request error to {url_for_request}: {e}",
+ exc_info=True)
+ return {
+ "error": True,
+ "status_code": -4,
"message": f"Unexpected error: {str(e)}"
}
+ async def get_all_panel_users(
+ self,
+ page_size: int = 100,
+ log_responses: bool = False) -> Optional[List[Dict[str, Any]]]:
+ all_users = []
+ start_offset = 0
+ while True:
+ params = {"size": page_size, "start": start_offset}
+ response_data = await self._request(
+ "GET",
+ "/users",
+ params=params,
+ log_full_response=log_responses)
+
+ if not response_data or response_data.get("error"):
+ logging.error(
+ f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}"
+ )
+ return None
+ users_batch = response_data.get("response", {}).get("users", [])
+ if not users_batch: break
+ all_users.extend(users_batch)
+ if len(users_batch) < page_size: break
+ start_offset += page_size
+ await asyncio.sleep(0.1)
+ logging.info(f"Fetched {len(all_users)} users from panel API.")
+ return all_users
+
+ async def get_user_by_uuid(
+ self,
+ user_uuid: str,
+ log_response: bool = True) -> Optional[Dict[str, Any]]:
+ endpoint = f"/users/{user_uuid}"
+ full_response = await self._request("GET",
+ endpoint,
+ log_full_response=log_response)
+ if full_response and not full_response.get(
+ "error") and "response" in full_response:
+ return full_response.get("response")
+
+ return None
+
async def get_users_by_filter(
self,
- username: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
- """Fetches users from panel by username."""
- if not username:
- logging.warning("get_users_by_filter called without username.")
- return None
+ telegram_id: Optional[int] = None,
+ username: Optional[str] = None,
+ email: Optional[str] = None,
+ log_response: bool = True) -> Optional[List[Dict[str, Any]]]:
- params = {"username": username}
- response_data = await self._request("GET", "/users", params=params)
+ response_data = None
+ filter_used_log = "No filter specified"
- if response_data and not response_data.get("error"):
- users_list = response_data.get("response", {}).get("users", [])
- logging.info(
- f"Found {len(users_list)} panel users matching filter: {params}"
+ if telegram_id is not None:
+ filter_used_log = f"telegramId={telegram_id}"
+ endpoint = f"/users/by-telegram-id/{telegram_id}"
+ response_data = await self._request("GET",
+ endpoint,
+ log_full_response=log_response)
+
+ if response_data and not response_data.get(
+ "error") and "response" in response_data and isinstance(
+ response_data["response"], list):
+ return response_data["response"]
+ elif response_data and response_data.get("errorCode") == "A062":
+ logging.info(
+ f"Panel API: Users not found for {filter_used_log}")
+ return []
+
+ elif username is not None:
+ filter_used_log = f"username={username}"
+ endpoint = f"/users/by-username/{username}"
+ response_data = await self._request("GET",
+ endpoint,
+ log_full_response=log_response)
+
+ if response_data and not response_data.get(
+ "error") and "response" in response_data and isinstance(
+ response_data["response"], dict):
+ return [response_data["response"]]
+ elif response_data and response_data.get("errorCode") == "A062":
+ logging.info(
+ f"Panel API: User not found for {filter_used_log}")
+ return []
+
+ elif email is not None:
+ filter_used_log = f"email={email}"
+ endpoint = f"/users/by-email/{email}"
+ response_data = await self._request("GET",
+ endpoint,
+ log_full_response=log_response)
+
+ if response_data and not response_data.get(
+ "error") and "response" in response_data and isinstance(
+ response_data["response"], list):
+ return response_data["response"]
+ elif response_data and response_data.get("errorCode") == "A062":
+ logging.info(
+ f"Panel API: Users not found for {filter_used_log}")
+ return []
+
+ if not telegram_id and not username and not email:
+ logging.warning(
+ "get_users_by_filter called without any specific filter criteria."
)
- return users_list
+ return []
+
logging.error(
- f"Failed to fetch panel users with filter {params}. Response: {response_data}"
+ f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}"
)
return None
async def create_panel_user(
- self,
- username: str,
- telegram_id: Optional[int] = None,
- email: Optional[str] = None,
- default_expire_days: int = 1,
- default_traffic_limit_bytes: int = 0,
- default_traffic_limit_strategy: str = "NO_RESET",
- specific_inbound_uuids: Optional[List[str]] = None,
- activate_all_inbounds_default_flag: bool = True
- ) -> Optional[Dict[str, Any]]:
+ self,
+ username_on_panel: str,
+ telegram_id: Optional[int] = None,
+ email: Optional[str] = None,
+ default_expire_days: int = 1,
+ default_traffic_limit_bytes: int = 0,
+ default_traffic_limit_strategy: str = "NO_RESET",
+ specific_inbound_uuids: Optional[List[str]] = None,
+ activate_all_inbounds_default_flag: bool = True,
+ description: Optional[str] = None,
+ tag: Optional[str] = None,
+ status: str = "ACTIVE",
+ log_response: bool = True) -> Optional[Dict[str, Any]]:
- if not (6 <= len(username) <= 34
- and username.replace('_', '').replace('-', '').isalnum()):
- msg = f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
- logging.error(msg)
- return {
- "error": True,
- "status_code": 400,
- "message": msg,
- "response": {
+ if not (6 <= len(username_on_panel) <= 34 and
+ username_on_panel.replace('_', '').replace('-', '').isalnum()):
+ if not (username_on_panel.startswith("tg_")
+ and username_on_panel.split("tg_")[-1].isdigit()):
+ msg = f"Panel username '{username_on_panel}' does not meet panel requirements."
+ logging.error(msg)
+ return {
+ "error": True,
+ "status_code": 400,
"message": msg,
- "errorCode": "VALIDATION_ERROR"
+ "errorCode": "VALIDATION_ERROR_USERNAME"
}
- }
now = datetime.now(timezone.utc)
expire_at_dt = now + timedelta(days=default_expire_days)
@@ -171,83 +329,86 @@ class PanelApiService:
timespec='milliseconds').replace('+00:00', 'Z')
payload: Dict[str, Any] = {
- "username": username,
+ "username": username_on_panel,
+ "status": status.upper(),
"expireAt": expire_at_iso,
- "trafficLimitStrategy": default_traffic_limit_strategy,
+ "trafficLimitStrategy": default_traffic_limit_strategy.upper(),
"trafficLimitBytes": default_traffic_limit_bytes,
}
if specific_inbound_uuids:
payload["activeUserInbounds"] = specific_inbound_uuids
-
payload["activateAllInbounds"] = False
else:
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
-
if telegram_id is not None: payload["telegramId"] = telegram_id
if email: payload["email"] = email
+ if description: payload["description"] = description
+ if tag: payload["tag"] = tag
- return await self._request("POST", "/users", json=payload)
+ response = await self._request("POST",
+ "/users",
+ json=payload,
+ log_full_response=log_response)
+ if response and not response.get("error") and "response" in response:
+ logging.info(
+ f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response',{}).get('uuid')})."
+ )
+ return response
+
+ logging.error(
+ f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}"
+ )
+ return response
async def update_user_details_on_panel(
- self, user_uuid: str,
- update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- if 'uuid' not in update_payload: update_payload['uuid'] = user_uuid
-
- update_payload.pop('activateAllInbounds', None)
+ self,
+ user_uuid: str,
+ update_payload: Dict[str, Any],
+ log_response: bool = True) -> Optional[Dict[str, Any]]:
+ if 'uuid' not in update_payload:
+ update_payload['uuid'] = user_uuid
full_response = await self._request("PATCH",
"/users",
- json=update_payload)
+ json=update_payload,
+ log_full_response=log_response)
if full_response and not full_response.get(
- "error") and full_response.get("response"):
+ "error") and "response" in full_response:
logging.info(f"User {user_uuid} details updated on panel.")
return full_response.get("response")
+
logging.error(
- f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}"
+ f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}"
)
return None
- async def get_all_panel_users(self,
- page_size: int = 100
- ) -> Optional[List[Dict[str, Any]]]:
- all_users = []
- start_offset = 0
- while True:
- params = {"size": page_size, "start": start_offset}
- response_data = await self._request("GET", "/users", params=params)
- if not response_data or response_data.get("error"):
- logging.error(
- f"Failed to fetch panel users batch: {response_data}")
- return None
- users_batch = response_data.get("response", {}).get("users", [])
- if not users_batch: break
- all_users.extend(users_batch)
- if len(users_batch) < page_size: break
- start_offset += page_size
- logging.info(f"Fetched {len(all_users)} users from panel API.")
- return all_users
+ async def update_user_status_on_panel(self,
+ user_uuid: str,
+ enable: bool,
+ log_response: bool = True) -> bool:
+ action = "enable" if enable else "disable"
+ endpoint = f"/users/{user_uuid}/actions/{action}"
+ response_data = await self._request("POST",
+ endpoint,
+ log_full_response=log_response)
- async def get_user_by_uuid(self,
- user_uuid: str) -> Optional[Dict[str, Any]]:
- full_response = await self._request("GET", f"/users/{user_uuid}")
- if full_response and not full_response.get(
- "error") and full_response.get("response"):
- return full_response.get("response")
- return None
+ if response_data and not response_data.get(
+ "error") and "response" in response_data:
+ actual_status = response_data.get("response", {}).get("status")
+ expected_status = "ACTIVE" if enable else "DISABLED"
+ if actual_status == expected_status:
+ logging.info(
+ f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})."
+ )
+ return True
+ else:
+ logging.warning(
+ f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'."
+ )
+ return False
- async def update_user_status_on_panel(self, user_uuid: str,
- enable: bool) -> bool:
- endpoint = f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
- response_data = await self._request("POST", endpoint)
- if response_data and not response_data.get("error") and (
- response_data.get("response")
- or response_data.get("status") == "success"):
- logging.info(
- f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}."
- )
- return True
logging.error(
- f"Failed to update user {user_uuid} status on panel. Resp: {response_data}"
+ f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}"
)
return False
@@ -255,5 +416,25 @@ class PanelApiService:
self,
short_uuid_or_sub_uuid: str,
client_type: Optional[str] = None) -> Optional[str]:
- if not self.settings.PANEL_API_URL: return None
- return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
+ if not self.settings.PANEL_API_URL:
+ logging.error(
+ "PANEL_API_URL not set, cannot generate subscription link.")
+ return None
+ base_sub_url = f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
+ if client_type:
+ return f"{base_sub_url}/{client_type.lower()}"
+ return base_sub_url
+
+ async def update_bot_db_sync_status(self,
+ session: AsyncSession,
+ status: str,
+ details: str,
+ users_processed: int = 0,
+ subs_synced: int = 0):
+ await panel_sync_dal.update_panel_sync_status(session, status, details,
+ users_processed,
+ subs_synced)
+
+ async def get_bot_db_last_sync_status(
+ self, session: AsyncSession) -> Optional[PanelSyncStatus]:
+ return await panel_sync_dal.get_panel_sync_status(session)
diff --git a/bot/services/payment_service.py b/bot/services/payment_service.py
index 1f48088..4bb96e6 100644
--- a/bot/services/payment_service.py
+++ b/bot/services/payment_service.py
@@ -1,10 +1,12 @@
import uuid
import logging
-from yookassa import Configuration, Payment
-from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
-
+import asyncio
from typing import Optional, Dict, Any, List
+from yookassa import Configuration, Payment as YooKassaPayment
+from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
+from yookassa.domain.common.confirmation_type import ConfirmationType
+
from config.settings import Settings
@@ -14,61 +16,84 @@ class YooKassaService:
shop_id: Optional[str],
secret_key: Optional[str],
configured_return_url: Optional[str],
- bot_username_for_default: Optional[str] = None,
+ bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None):
+
self.settings = settings_obj
if not shop_id or not secret_key:
logging.warning(
- "YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled."
- )
+ "YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
+ "Payment functionality will be DISABLED.")
self.configured = False
else:
- Configuration.account_id = shop_id
- Configuration.secret_key = secret_key
- self.configured = True
- logging.info(f"YooKassa configured for shop_id: {shop_id}")
+ try:
+ Configuration.configure(shop_id, secret_key)
+ self.configured = True
+ logging.info(
+ f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
+ except Exception as e:
+ logging.error(f"Failed to configure YooKassa SDK: {e}",
+ exc_info=True)
+ self.configured = False
if configured_return_url:
self.return_url = configured_return_url
- elif bot_username_for_default:
- self.return_url = f"https://t.me/{bot_username_for_default}"
+ elif bot_username_for_default_return:
+ self.return_url = f"https://t.me/{bot_username_for_default_return}"
logging.info(
- f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}"
+ f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}"
)
else:
- self.return_url = "https://example.com/payment_error_no_return_url"
+ self.return_url = "https://example.com/payment_error_no_return_url_configured"
logging.warning(
- f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}"
+ f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
+ f"Using placeholder: {self.return_url}. Payments may not complete correctly."
)
logging.info(
- f"YooKassa Service effective return_url: {self.return_url}")
+ f"YooKassa Service effective return_url for payments: {self.return_url}"
+ )
async def create_payment(
- self, amount: float, currency: str, description: str,
- metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ self,
+ amount: float,
+ currency: str,
+ description: str,
+ metadata: Dict[str, Any],
+ receipt_email: Optional[str] = None,
+ receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
if not self.settings:
logging.error(
- "YooKassaService: Settings object not available for receipt creation."
- )
- return {
- "error": True,
- "internal_message": "Service settings not initialized."
- }
-
- if not self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
- logging.error(
- "CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact."
+ "YooKassaService: Settings object not available. Cannot create payment with receipt details."
)
return {
"error":
True,
"internal_message":
- "YooKassa receipt email not configured by admin."
+ "Service settings (Settings object) not initialized."
+ }
+
+ customer_contact_for_receipt = {}
+ if receipt_email:
+ customer_contact_for_receipt["email"] = receipt_email
+ elif receipt_phone:
+ customer_contact_for_receipt["phone"] = receipt_phone
+ elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
+ customer_contact_for_receipt[
+ "email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
+ else:
+ logging.error(
+ "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set."
+ )
+ return {
+ "error":
+ True,
+ "internal_message":
+ "YooKassa receipt customer contact (email/phone) missing and no default email configured."
}
try:
@@ -79,15 +104,15 @@ class YooKassaService:
})
builder.set_capture(True)
builder.set_confirmation({
- "type": "redirect",
+ "type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
- receipt_items: List[Dict[str, Any]] = [{
+ receipt_items_list: List[Dict[str, Any]] = [{
"description":
- description,
+ description[:128],
"quantity":
"1.00",
"amount": {
@@ -102,87 +127,122 @@ class YooKassaService:
self.settings.YOOKASSA_PAYMENT_SUBJECT
}]
- receipt_customer: Dict[str, str] = {}
- if self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
- receipt_customer[
- "email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
-
- if not receipt_customer:
- logging.error(
- "YooKassa: No customer contact (email/phone) for receipt.")
- return {
- "error": True,
- "internal_message": "Receipt customer contact missing."
- }
-
- receipt_payload: Dict[str, Any] = {
- "customer": receipt_customer,
- "items": receipt_items
+ receipt_data_dict: Dict[str, Any] = {
+ "customer": customer_contact_for_receipt,
+ "items": receipt_items_list
}
- builder.set_receipt(receipt_payload)
+ builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logging.info(
- f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}"
+ f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
+ f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
)
- res = Payment.create(payment_request, idempotence_key)
+ loop = asyncio.get_event_loop()
+ response = await loop.run_in_executor(
+ None, lambda: YooKassaPayment.create(payment_request,
+ idempotence_key))
+
logging.info(
- f"YooKassa Payment.create response: ID={res.id}, Status={res.status}"
+ f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}"
)
return {
"id":
- res.id,
+ response.id,
"confirmation_url":
- res.confirmation.confirmation_url
- if res.confirmation else None,
+ response.confirmation.confirmation_url
+ if response.confirmation else None,
"status":
- res.status,
+ response.status,
"metadata":
- res.metadata,
+ response.metadata,
"amount_value":
- float(res.amount.value),
+ float(response.amount.value),
"amount_currency":
- res.amount.currency,
- "idempotence_key":
+ response.amount.currency,
+ "idempotence_key_used":
idempotence_key,
"paid":
- res.paid,
+ response.paid,
"refundable":
- res.refundable,
+ response.refundable,
"created_at":
- res.created_at.isoformat() if hasattr(
- res.created_at, 'isoformat') else str(res.created_at)
+ response.created_at.isoformat() if hasattr(
+ response.created_at, 'isoformat') else str(
+ response.created_at),
+ "description_from_yk":
+ response.description,
+ "test_mode":
+ response.test if hasattr(response, 'test') else None
}
except Exception as e:
logging.error(f"YooKassa payment creation failed: {e}",
exc_info=True)
return None
- async def get_payment_info(self,
- payment_id: str) -> Optional[Dict[str, Any]]:
+ async def get_payment_info(
+ self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error(
"YooKassa is not configured. Cannot get payment info.")
return None
try:
- payment_info = Payment.find_one(payment_id)
- if payment_info:
+ logging.info(
+ f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
+ )
+
+ loop = asyncio.get_event_loop()
+ payment_info_yk = await loop.run_in_executor(
+ None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
+
+ if payment_info_yk:
+ logging.info(
+ f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
+ )
return {
- "id": payment_info.id,
- "status": payment_info.status,
- "paid": payment_info.paid,
- "amount_value": float(payment_info.amount.value),
- "amount_currency": payment_info.amount.currency,
- "metadata": payment_info.metadata,
- "description": payment_info.description,
+ "id":
+ payment_info_yk.id,
+ "status":
+ payment_info_yk.status,
+ "paid":
+ payment_info_yk.paid,
+ "amount_value":
+ float(payment_info_yk.amount.value),
+ "amount_currency":
+ payment_info_yk.amount.currency,
+ "metadata":
+ payment_info_yk.metadata,
+ "description":
+ payment_info_yk.description,
+ "refundable":
+ payment_info_yk.refundable,
+ "created_at":
+ payment_info_yk.created_at.isoformat() if hasattr(
+ payment_info_yk.created_at, 'isoformat') else str(
+ payment_info_yk.created_at),
+ "captured_at":
+ payment_info_yk.captured_at.isoformat()
+ if payment_info_yk.captured_at and hasattr(
+ payment_info_yk.captured_at, 'isoformat') else None,
+ "payment_method_type":
+ payment_info_yk.payment_method.type
+ if payment_info_yk.payment_method else None,
+ "test_mode":
+ payment_info_yk.test
+ if hasattr(payment_info_yk, 'test') else None
}
- return None
+ else:
+ logging.warning(
+ f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
+ )
+ return None
except Exception as e:
logging.error(
- f"YooKassa get payment info for {payment_id} failed: {e}")
+ f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
+ exc_info=True)
return None
diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py
index 31a42d2..6b358d7 100644
--- a/bot/services/promo_code_service.py
+++ b/bot/services/promo_code_service.py
@@ -1,88 +1,77 @@
import logging
-import aiosqlite
-from datetime import datetime, timezone
-from typing import Optional, Dict, Callable, Any, Tuple
-
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Optional, Tuple, Dict
from aiogram import Bot
+
from config.settings import Settings
-from db.database import get_promo_code_by_code, increment_promo_activation, get_db_connection_manager, _setup_db_connection
+
+from db.dal import promo_code_dal, user_dal, subscription_dal
+from db.models import PromoCode, User
+
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
class PromoCodeService:
- def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
+ def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
- self.db_conn_provider = db_conn_provider
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
- async def apply_promo_code(self, user_id: int, code_input: str,
+ async def apply_promo_code(self, session: AsyncSession, user_id: int,
+ code_input: str,
user_lang: str) -> Tuple[bool, str]:
- """
- Applies a promo code for a user.
- Returns: (success_status: bool, message_text_for_user: str)
- """
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
- async with self.db_conn_provider() as db:
- await _setup_db_connection(db)
- try:
- promo_data = await get_promo_code_by_code(code_input_upper,
- db_conn=db)
+ promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
+ session, code_input_upper)
- if not promo_data:
- return False, _("promo_code_not_found",
- code=code_input_upper)
+ if not promo_data:
+ return False, _("promo_code_not_found", code=code_input_upper)
- if promo_data['current_activations'] >= promo_data[
- 'max_activations']:
- return False, _("promo_code_max_activations_reached",
- code=code_input_upper)
+ existing_activation = await promo_code_dal.get_user_activation_for_promo(
+ session, promo_data.promo_code_id, user_id)
+ if existing_activation:
+ return False, _("promo_code_already_used_by_user",
+ code=code_input_upper)
- active_sub = await self.subscription_service.get_active_subscription(
- user_id)
- if not active_sub:
- return False, _("promo_code_no_active_subscription")
+ active_sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, user_id)
+ if not active_sub:
+ return False, _("promo_code_no_active_subscription")
- bonus_days = promo_data['bonus_days']
+ bonus_days = promo_data.bonus_days
- new_end_date = await self.subscription_service.extend_active_subscription_days(
- user_id=user_id,
- bonus_days=bonus_days,
- db_conn=db,
- reason=f"promo code {code_input_upper}")
+ new_end_date = await self.subscription_service.extend_active_subscription_days(
+ session=session,
+ user_id=user_id,
+ bonus_days=bonus_days,
+ reason=f"promo code {code_input_upper}")
- if new_end_date:
+ if new_end_date:
- activation_success = await increment_promo_activation(
- promo_data['promo_code_id'],
- user_id,
- db_conn=db,
- payment_id=None)
- if activation_success:
- await db.commit()
- return True, _(
- "promo_code_applied_success",
- code=code_input_upper,
- bonus_days=bonus_days,
- new_end_date=new_end_date.strftime('%Y-%m-%d'))
- else:
+ activation_recorded = await promo_code_dal.record_promo_activation(
+ session, promo_data.promo_code_id, user_id, payment_id=None)
+ promo_incremented = await promo_code_dal.increment_promo_code_usage(
+ session, promo_data.promo_code_id)
- await db.rollback()
- return False, _("promo_code_invalid_or_expired")
- else:
- await db.rollback()
- return False, _("error_applying_promo_bonus")
+ if activation_recorded and promo_incremented:
+
+ return True, _("promo_code_applied_success",
+ code=code_input_upper,
+ bonus_days=bonus_days,
+ new_end_date=new_end_date.strftime('%Y-%m-%d'))
+ else:
- except Exception as e:
logging.error(
- f"Error applying promo code {code_input_upper} for user {user_id}: {e}",
- exc_info=True)
- await db.rollback()
- return False, _("error_try_again")
+ f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}"
+ )
+ return False, _("error_applying_promo_bonus")
+ else:
+
+ return False, _("error_applying_promo_bonus")
diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py
index 15d89fe..6254c18 100644
--- a/bot/services/referral_service.py
+++ b/bot/services/referral_service.py
@@ -1,48 +1,39 @@
import logging
-import aiosqlite
-from typing import Optional, Dict, Callable, Any, TYPE_CHECKING, Tuple
+from sqlalchemy.ext.asyncio import AsyncSession
+from typing import Optional, Dict, Any
from aiogram import Bot
-from datetime import datetime
+from datetime import datetime, timezone, timedelta
from config.settings import Settings
-from db.database import get_user
+from db.dal import user_dal
+from db.models import User
+from db.dal import subscription_dal
from bot.middlewares.i18n import JsonI18n
-
-if TYPE_CHECKING:
- from .subscription_service import SubscriptionService
+from .subscription_service import SubscriptionService
class ReferralService:
- def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
- subscription_service_instance: 'SubscriptionService',
- bot: Bot, i18n: JsonI18n):
- self.db_conn_provider = db_conn_provider
+ def __init__(self, settings: Settings,
+ subscription_service: SubscriptionService, bot: Bot,
+ i18n: JsonI18n):
self.settings = settings
- self.subscription_service = subscription_service_instance
+ self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
- async def process_new_user_referral(self, referee_user_id: int,
- inviter_user_id: Optional[int]):
- if inviter_user_id and referee_user_id != inviter_user_id:
-
- logging.info(
- f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}."
- )
-
- pass
-
async def apply_referral_bonuses_for_payment(
- self, referee_user_id: int, purchased_subscription_months: int,
- db_conn: aiosqlite.Connection) -> Dict[str, Any]:
- """Applies bonuses. Notifies inviter. Returns referee bonus details."""
+ self, session: AsyncSession, referee_user_id: int,
+ purchased_subscription_months: int) -> Dict[str, Any]:
+
referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None
+ inviter_bonus_successfully_applied = False
+
try:
- referee_user_row = await get_user(referee_user_id, db_conn=db_conn)
- if not referee_user_row or referee_user_row[
- 'referred_by_id'] is None:
+ referee_user_model = await user_dal.get_user_by_id(
+ session, referee_user_id)
+ if not referee_user_model or referee_user_model.referred_by_id is None:
logging.debug(
f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses."
)
@@ -51,75 +42,177 @@ class ReferralService:
"referee_new_end_date": None
}
- inviter_user_id = referee_user_row['referred_by_id']
- inviter_user_row = await get_user(inviter_user_id, db_conn=db_conn)
+ inviter_user_id = referee_user_model.referred_by_id
+ inviter_user_model = await user_dal.get_user_by_id(
+ session, inviter_user_id)
- referee_name = referee_user_row[
- 'first_name'] or f"User {referee_user_id}"
+ referee_name_for_msg = referee_user_model.first_name or f"User {referee_user_id}"
- default_lang_for_placeholder = getattr(self.settings,
- 'DEFAULT_LANGUAGE', 'en')
- inviter_name = inviter_user_row[
- 'first_name'] if inviter_user_row else self.i18n.gettext(
- default_lang_for_placeholder, "friend_placeholder")
+ default_lang_for_placeholder = self.settings.DEFAULT_LANGUAGE
+ inviter_name_for_referee_msg = (
+ inviter_user_model.first_name if inviter_user_model
+ and inviter_user_model.first_name else self.i18n.gettext(
+ default_lang_for_placeholder, "friend_placeholder"))
- inviter_bonus = self.settings.referral_bonus_inviter.get(
+ inviter_bonus_days = self.settings.referral_bonus_inviter.get(
purchased_subscription_months)
- referee_bonus = self.settings.referral_bonus_referee.get(
+ referee_bonus_days = self.settings.referral_bonus_referee.get(
purchased_subscription_months)
- if inviter_bonus and inviter_bonus > 0 and inviter_user_row:
- new_end_date_inviter = await self.subscription_service.extend_subscription_for_referral(
- user_id=inviter_user_id,
- bonus_days=inviter_bonus,
- db_conn=db_conn)
- if new_end_date_inviter:
- logging.info(
- f"Bonus applied for inviter {inviter_user_id}.")
- try:
- inviter_lang = inviter_user_row.get(
- 'language_code', default_lang_for_placeholder)
- _i = lambda k, **kw: self.i18n.gettext(
- inviter_lang, k, **kw)
- await self.bot.send_message(
- inviter_user_id,
- _i("referral_bonus_inviter_notification_extended",
- days=inviter_bonus,
- referee_name=referee_name,
- new_end_date=new_end_date_inviter.strftime(
- '%Y-%m-%d')))
- except Exception as e:
- logging.error(
- f"Failed to send bonus notification to inviter {inviter_user_id}: {e}"
- )
- else:
+ if inviter_bonus_days and inviter_bonus_days > 0:
+ if not inviter_user_model:
+
logging.warning(
- f"Failed to apply bonus subscription extension for inviter {inviter_user_id}."
+ f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus."
)
-
- if referee_bonus and referee_bonus > 0:
- new_end_date_referee = await self.subscription_service.extend_subscription_for_referral(
- user_id=referee_user_id,
- bonus_days=referee_bonus,
- db_conn=db_conn,
- is_referee_bonus=True)
- if new_end_date_referee:
- logging.info(
- f"Bonus applied for referee {referee_user_id}.")
- referee_final_end_date = new_end_date_referee
- referee_bonus_applied_days = referee_bonus
else:
+
+ inviter_panel_uuid, inviter_panel_sub_link_id, _, _ = await self.subscription_service._get_or_create_panel_user_link_details(
+ session, inviter_user_id, inviter_user_model)
+
+ if not inviter_panel_uuid:
+ logging.warning(
+ f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel."
+ )
+
+ else:
+ new_end_date_inviter = await self.subscription_service.extend_active_subscription_days(
+ session=session,
+ user_id=inviter_user_id,
+ bonus_days=inviter_bonus_days,
+ reason=f"referral bonus from {referee_name_for_msg}"
+ )
+
+ if new_end_date_inviter:
+ inviter_bonus_successfully_applied = True
+ logging.info(
+ f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}."
+ )
+
+ try:
+ inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
+ _i = lambda k, **kw: self.i18n.gettext(
+ inviter_lang, k, **kw)
+ await self.bot.send_message(
+ inviter_user_id,
+ _i("referral_bonus_inviter_notification_extended",
+ days=inviter_bonus_days,
+ referee_name=referee_name_for_msg,
+ new_end_date=new_end_date_inviter.
+ strftime('%Y-%m-%d')))
+ except Exception as e_notify_inviter:
+ logging.error(
+ f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}"
+ )
+ else:
+
+ logging.info(
+ f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days."
+ )
+
+ bonus_start_date = datetime.now(timezone.utc)
+ bonus_end_date = bonus_start_date + timedelta(
+ days=inviter_bonus_days)
+
+ if not inviter_panel_sub_link_id:
+ logging.error(
+ f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch."
+ )
+ else:
+ bonus_sub_payload = {
+ "user_id":
+ inviter_user_id,
+ "panel_user_uuid":
+ inviter_panel_uuid,
+ "panel_subscription_uuid":
+ inviter_panel_sub_link_id,
+ "start_date":
+ bonus_start_date,
+ "end_date":
+ bonus_end_date,
+ "duration_months":
+ 0,
+ "is_active":
+ True,
+ "status_from_panel":
+ "ACTIVE_BONUS",
+ "traffic_limit_bytes":
+ self.settings.
+ PANEL_USER_DEFAULT_TRAFFIC_BYTES,
+ }
+ try:
+ await subscription_dal.deactivate_other_active_subscriptions(
+ session, inviter_panel_uuid,
+ inviter_panel_sub_link_id)
+ bonus_sub = await subscription_dal.upsert_subscription(
+ session, bonus_sub_payload)
+
+ panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel(
+ inviter_panel_uuid, {
+ "expireAt":
+ bonus_end_date.isoformat(
+ timespec='milliseconds').
+ replace('+00:00', 'Z'),
+ "status":
+ "ACTIVE",
+ })
+ if panel_update_success:
+ inviter_bonus_successfully_applied = True
+ logging.info(
+ f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}."
+ )
+
+ inviter_lang = inviter_user_model.language_code or default_lang_for_placeholder
+ _i = lambda k, **kw: self.i18n.gettext(
+ inviter_lang, k, **kw)
+ await self.bot.send_message(
+ inviter_user_id,
+ _i("referral_bonus_inviter_notification_new_sub",
+ days=inviter_bonus_days,
+ referee_name=
+ referee_name_for_msg,
+ new_end_date=bonus_end_date.
+ strftime('%Y-%m-%d')))
+ else:
+ logging.warning(
+ f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel."
+ )
+
+ except Exception as e_create_bonus_sub:
+ logging.error(
+ f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}",
+ exc_info=True)
+
+ if referee_bonus_days and referee_bonus_days > 0:
+
+ new_end_date_referee = await self.subscription_service.extend_active_subscription_days(
+ session=session,
+ user_id=referee_user_id,
+ bonus_days=referee_bonus_days,
+ reason=
+ f"referee bonus (invited by {inviter_name_for_referee_msg})"
+ )
+ if new_end_date_referee:
+ referee_final_end_date = new_end_date_referee
+ referee_bonus_applied_days = referee_bonus_days
+ logging.info(
+ f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}."
+ )
+ else:
+
logging.warning(
- f"Failed to apply bonus subscription extension for referee {referee_user_id}."
+ f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)."
)
return {
"referee_bonus_applied_days": referee_bonus_applied_days,
- "referee_new_end_date": referee_final_end_date
+ "referee_new_end_date": referee_final_end_date,
+ "inviter_bonus_applied_flag":
+ inviter_bonus_successfully_applied
}
except Exception as e:
logging.error(
- f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",
+ f"Error in apply_referral_bonuses_for_payment for referee {referee_user_id}: {e}",
exc_info=True)
raise
diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py
index 1e09071..4574c58 100644
--- a/bot/services/subscription_service.py
+++ b/bot/services/subscription_service.py
@@ -1,670 +1,639 @@
import logging
-import aiosqlite
+from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta, timezone
-from typing import Optional, Dict, Any, List, Callable, Awaitable
+from typing import Optional, Dict, Any, List, Tuple
+
+from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
+from db.models import User, Subscription
from config.settings import Settings
-from db.database import get_db_connection_manager, _setup_db_connection, get_user, has_had_any_subscription as db_has_had_any_subscription
from .panel_api_service import PanelApiService
-if False:
- from .referral_service import ReferralService
-
class SubscriptionService:
- def __init__(self, db_conn_provider: Callable[[], Any], settings: Settings,
- panel_service: PanelApiService):
- self.db_conn_provider = db_conn_provider
+ def __init__(self, settings: Settings, panel_service: PanelApiService):
self.settings = settings
self.panel_service = panel_service
- async def _get_db(
+ async def get_user_language(self, session: AsyncSession,
+ user_id: int) -> str:
+ user_record = await user_dal.get_user_by_id(session, user_id)
+ return user_record.language_code if user_record and user_record.language_code else self.settings.DEFAULT_LANGUAGE
+
+ async def has_had_any_subscription(self, session: AsyncSession,
+ user_id: int) -> bool:
+
+ return await subscription_dal.has_any_subscription_for_user(
+ session, user_id)
+
+ async def _get_or_create_panel_user_link_details(
self,
- existing_conn: Optional[aiosqlite.Connection] = None
- ) -> tuple[aiosqlite.Connection, bool]:
- """Helper to get a DB connection. Returns (connection, should_manage_flag)."""
- if existing_conn:
- return existing_conn, False
+ session: AsyncSession,
+ user_id: int,
+ db_user: Optional[User] = None
+ ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
+ if not db_user:
+ db_user = await user_dal.get_user_by_id(session, user_id)
- conn_manager = self.db_conn_provider()
- conn = await conn_manager.__aenter__()
- try:
- await _setup_db_connection(conn)
- except Exception as e:
- await conn_manager.__aexit__(type(e), e, e.__traceback__)
- raise
- return conn, True
+ if not db_user:
+ logging.error(
+ f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed."
+ )
+ return None, None, None, False
- async def _release_db(self,
- db: aiosqlite.Connection,
- should_manage: bool,
- exc_type=None,
- exc_val=None,
- exc_tb=None):
- """Helper to release/close a DB connection if this service instance opened it."""
- if should_manage:
- await db.__aexit__(exc_type, exc_val, exc_tb)
+ current_local_panel_uuid = db_user.panel_user_uuid
+ panel_username_on_panel_standard = f"tg_{user_id}"
- async def get_user_language(self, user_id: int) -> str:
- """Fetches the user's language preference from the local database."""
- db, should_manage = await self._get_db()
- try:
- user_record = await get_user(user_id, db_conn=db)
- default_lang = self.settings.DEFAULT_LANGUAGE
- return user_record[
- 'language_code'] if user_record and 'language_code' in user_record.keys(
- ) and user_record['language_code'] else default_lang
- finally:
- await self._release_db(db, should_manage)
+ panel_user_obj_from_api = None
+ panel_user_created_or_linked_now = False
- async def get_panel_user_uuid(
- self,
- user_id: int,
- db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]:
- """Fetches panel_user_uuid for a given Telegram user_id from local DB."""
- db_to_use, should_manage_this_conn = await self._get_db(db_conn)
- try:
- user_record = await get_user(user_id, db_conn=db_to_use)
- return user_record[
- 'panel_user_uuid'] if user_record and 'panel_user_uuid' in user_record.keys(
- ) and user_record['panel_user_uuid'] else None
- finally:
- await self._release_db(db_to_use, should_manage_this_conn)
+ panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
+ telegram_id=user_id)
+ if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
+ panel_user_obj_from_api = panel_users_by_tg_id_list[0]
+ logging.info(
+ f"Found panel user by telegramId {user_id}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}"
+ )
+ elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1:
+ logging.error(
+ f"CRITICAL: Multiple panel users found for telegramId {user_id}. Manual intervention needed."
+ )
+ return None, None, None, False
- async def has_had_any_subscription(
- self,
- user_id: int,
- db_conn: Optional[aiosqlite.Connection] = None) -> bool:
- """Checks if the user has any record in the subscriptions table."""
- db_to_use, should_manage_this_conn = await self._get_db(db_conn)
- try:
- return await db_has_had_any_subscription(user_id,
- db_conn=db_to_use)
- finally:
- await self._release_db(db_to_use, should_manage_this_conn)
+ if not panel_user_obj_from_api:
+ if current_local_panel_uuid:
+
+ logging.info(
+ f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid."
+ )
+ panel_user_obj_from_api = await self.panel_service.get_user_by_uuid(
+ current_local_panel_uuid)
+ if not panel_user_obj_from_api:
+ logging.warning(
+ f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced."
+ )
+
+ else:
+
+ logging.info(
+ f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'."
+ )
+ creation_response = await self.panel_service.create_panel_user(
+ username_on_panel=panel_username_on_panel_standard,
+ telegram_id=user_id,
+ )
+ if creation_response and not creation_response.get(
+ "error") and creation_response.get("response"):
+ panel_user_obj_from_api = creation_response.get("response")
+ panel_user_created_or_linked_now = True
+
+ elif creation_response and creation_response.get(
+ "errorCode") == "A019":
+ logging.warning(
+ f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username."
+ )
+ fetched_by_username_list = await self.panel_service.get_users_by_filter(
+ username=panel_username_on_panel_standard)
+ if fetched_by_username_list and len(
+ fetched_by_username_list) == 1:
+ panel_user_obj_from_api = fetched_by_username_list[0]
+
+ if not panel_user_obj_from_api:
+ logging.error(
+ f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}"
+ )
+ return None, None, None, False
+
+ if not panel_user_obj_from_api:
+ logging.error(
+ f"Could not obtain panel user object for TG user {user_id} after all checks."
+ )
+
+ return current_local_panel_uuid if current_local_panel_uuid else None, None, None, panel_user_created_or_linked_now
+
+ actual_panel_uuid_from_api = panel_user_obj_from_api.get('uuid')
+ actual_panel_username_from_api = panel_user_obj_from_api.get(
+ 'username')
+ panel_telegram_id_from_api = panel_user_obj_from_api.get('telegramId')
+
+ if not actual_panel_uuid_from_api:
+ logging.error(
+ f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}"
+ )
+ return current_local_panel_uuid, None, None, panel_user_created_or_linked_now
+
+ needs_local_panel_uuid_update = False
+ if current_local_panel_uuid is None and actual_panel_uuid_from_api:
+ needs_local_panel_uuid_update = True
+ elif current_local_panel_uuid is not None and current_local_panel_uuid != actual_panel_uuid_from_api:
+ logging.warning(
+ f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
+ f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. "
+ f"Will attempt to update local to panel's version.")
+ needs_local_panel_uuid_update = True
+
+ if needs_local_panel_uuid_update:
+
+ conflicting_user_record = await user_dal.get_user_by_panel_uuid(
+ session, actual_panel_uuid_from_api)
+ if conflicting_user_record and conflicting_user_record.user_id != user_id:
+ logging.error(
+ f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) "
+ f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. "
+ f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed."
+ )
+
+ return None, None, None, False
+ else:
+
+ update_data_for_local_user = {
+ "panel_user_uuid": actual_panel_uuid_from_api
+ }
+
+ if actual_panel_username_from_api and actual_panel_username_from_api != panel_username_on_panel_standard and (
+ db_user.username is None
+ or db_user.username != actual_panel_username_from_api):
+ update_data_for_local_user[
+ "username"] = actual_panel_username_from_api
+
+ await user_dal.update_user(session, user_id,
+ update_data_for_local_user)
+ db_user.panel_user_uuid = actual_panel_uuid_from_api
+ if "username" in update_data_for_local_user:
+ db_user.username = update_data_for_local_user["username"]
+ panel_user_created_or_linked_now = True
+ current_local_panel_uuid = actual_panel_uuid_from_api
+ else:
+
+ pass
+
+ panel_telegram_id_int = None
+ if panel_telegram_id_from_api is not None:
+ try:
+ panel_telegram_id_int = int(panel_telegram_id_from_api)
+ except ValueError:
+ pass
+
+ if panel_user_obj_from_api and current_local_panel_uuid and panel_telegram_id_int != user_id:
+ logging.info(
+ f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
+ )
+ await self.panel_service.update_user_details_on_panel(
+ current_local_panel_uuid, {"telegramId": user_id})
+
+ panel_sub_link_id = panel_user_obj_from_api.get(
+ 'subscriptionUuid') or panel_user_obj_from_api.get('shortUuid')
+ panel_short_uuid = panel_user_obj_from_api.get('shortUuid')
+
+ if not panel_sub_link_id and current_local_panel_uuid:
+ logging.warning(
+ f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})."
+ )
+
+ return current_local_panel_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_or_linked_now
async def activate_trial_subscription(
- self, user_id: int) -> Optional[Dict[str, Any]]:
- """
- Activates a trial subscription for an eligible user.
- Manages its own database connection and transaction.
- Returns dict with trial details or specific error dict on failure/ineligibility.
- """
+ self, session: AsyncSession,
+ user_id: int) -> Optional[Dict[str, Any]]:
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
- logging.info(
- f"Trial subscription feature is disabled or duration invalid for user {user_id}."
- )
return {
"eligible": False,
"activated": False,
"message_key": "trial_feature_disabled"
}
- async with self.db_conn_provider() as db:
- await _setup_db_connection(db)
- try:
- if await db_has_had_any_subscription(user_id, db_conn=db):
- logging.info(
- f"User {user_id} has prior subscriptions. Trial not applicable."
- )
- return {
- "eligible": False,
- "activated": False,
- "message_key": "trial_not_eligible_already_subscribed"
- }
+ 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, cannot activate trial.")
+ return {
+ "eligible": False,
+ "activated": False,
+ "message_key": "user_not_found_for_trial"
+ }
- panel_user_uuid = await self.get_panel_user_uuid(user_id,
- db_conn=db)
- panel_subscription_uuid_for_link = None
- panel_short_uuid_for_link = None
- panel_user_interacted_now = False
- specific_inbounds = self.settings.parsed_default_panel_user_inbound_uuids
- panel_actual_subscription_url: Optional[str] = None
+ if await self.has_had_any_subscription(session, user_id):
+ return {
+ "eligible": False,
+ "activated": False,
+ "message_key": "trial_already_had_subscription_or_trial"
+ }
- if not panel_user_uuid:
- logging.info(
- f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user."
- )
- panel_username_to_create = f"tg_{user_id}"
- creation_response = await self.panel_service.create_panel_user(
- username=panel_username_to_create,
- telegram_id=user_id,
- default_expire_days=self.settings.TRIAL_DURATION_DAYS,
- default_traffic_limit_bytes=self.settings.
- trial_traffic_limit_bytes,
- default_traffic_limit_strategy=self.settings.
- PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
- specific_inbound_uuids=specific_inbounds,
- activate_all_inbounds_default_flag=False
- if specific_inbounds else True)
- panel_user_obj_from_api = None
- if creation_response and not creation_response.get(
- "error"):
- panel_user_obj_from_api = creation_response.get(
- "response")
- elif creation_response and creation_response.get(
- "errorCode") == "A019":
- existing_users_list = await self.panel_service.get_users_by_filter(
- username=panel_username_to_create)
- if existing_users_list and len(
- existing_users_list) == 1:
- panel_user_obj_from_api = existing_users_list[0]
+ panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
+ session, user_id, db_user)
- if panel_user_obj_from_api and panel_user_obj_from_api.get(
- 'uuid'):
- panel_user_uuid = panel_user_obj_from_api['uuid']
- panel_subscription_uuid_for_link = panel_user_obj_from_api.get(
- 'subscriptionUuid')
- panel_short_uuid_for_link = panel_user_obj_from_api.get(
- 'shortUuid')
- panel_actual_subscription_url = panel_user_obj_from_api.get(
- 'subscriptionUrl')
- await db.execute(
- "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
- (panel_user_uuid, user_id))
- panel_user_interacted_now = True
- if panel_user_obj_from_api.get(
- 'telegramId') != user_id:
- await self.panel_service.update_user_details_on_panel(
- panel_user_uuid, {"telegramId": user_id})
- else:
- logging.error(
- f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
- )
- await db.rollback()
- return {
- "eligible": True,
- "activated": False,
- "message_key": "trial_activation_failed"
- }
- else:
- panel_user_data = await self.panel_service.get_user_by_uuid(
- panel_user_uuid)
- if panel_user_data:
- panel_subscription_uuid_for_link = panel_user_data.get(
- 'subscriptionUuid')
- panel_short_uuid_for_link = panel_user_data.get(
- 'shortUuid')
- panel_actual_subscription_url = panel_user_data.get(
- 'subscriptionUrl')
+ if not panel_user_uuid or not panel_sub_link_id:
+ logging.error(
+ f"Failed to get panel link details for trial user {user_id}.")
+ return {
+ "eligible": True,
+ "activated": False,
+ "message_key": "trial_activation_failed_panel_link"
+ }
- if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
- panel_subscription_uuid_for_link = panel_short_uuid_for_link
- if not panel_subscription_uuid_for_link:
- logging.error(
- f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid})."
- )
- await db.rollback()
- return {
- "eligible": True,
- "activated": False,
- "message_key": "trial_activation_failed"
- }
+ start_date = datetime.now(timezone.utc)
+ end_date = start_date + timedelta(
+ days=self.settings.TRIAL_DURATION_DAYS)
- start_date = datetime.now(timezone.utc)
- end_date = start_date + timedelta(
- days=self.settings.TRIAL_DURATION_DAYS)
- await db.execute(
- "UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
- (panel_user_uuid, ))
- upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
- trial_traffic_val = self.settings.trial_traffic_limit_bytes
- params = (user_id, panel_user_uuid,
- panel_subscription_uuid_for_link,
- start_date.isoformat(), end_date.isoformat(),
- trial_traffic_val)
+ await subscription_dal.deactivate_other_active_subscriptions(
+ session, panel_user_uuid, panel_sub_link_id)
- upsert_cursor = await db.execute(upsert_sql, params)
- trial_subscription_id = upsert_cursor.lastrowid
- if not trial_subscription_id or trial_subscription_id == 0:
- id_fetch_cursor = await db.execute(
- "SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
- (panel_subscription_uuid_for_link, ))
- id_fetch_row = await id_fetch_cursor.fetchone()
- if id_fetch_cursor: await id_fetch_cursor.close()
- if id_fetch_row:
- trial_subscription_id = id_fetch_row['subscription_id']
- logging.info(
- f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}."
- )
+ trial_sub_data = {
+ "user_id": user_id,
+ "panel_user_uuid": panel_user_uuid,
+ "panel_subscription_uuid": panel_sub_link_id,
+ "start_date": start_date,
+ "end_date": end_date,
+ "duration_months": 0,
+ "is_active": True,
+ "status_from_panel": "TRIAL",
+ "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
+ }
+ try:
+ await subscription_dal.upsert_subscription(session, trial_sub_data)
+ except Exception as e_upsert:
+ logging.error(
+ f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
+ exc_info=True)
+ await session.rollback()
+ return {
+ "eligible": True,
+ "activated": False,
+ "message_key": "trial_activation_failed_db"
+ }
- panel_update_payload: Dict[str, Any] = {
- "uuid":
- panel_user_uuid,
- "expireAt":
- end_date.isoformat(timespec='milliseconds').replace(
- '+00:00', 'Z'),
- "status":
- "ACTIVE",
- "trafficLimitBytes":
- trial_traffic_val,
- "trafficLimitStrategy":
- self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
- }
- if specific_inbounds:
- panel_update_payload[
- "activeUserInbounds"] = specific_inbounds
- elif panel_user_interacted_now:
- panel_update_payload["activateAllInbounds"] = True
+ panel_update_payload: Dict[str, Any] = {
+ "uuid":
+ panel_user_uuid,
+ "expireAt":
+ end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
+ "status":
+ "ACTIVE",
+ "trafficLimitBytes":
+ self.settings.trial_traffic_limit_bytes,
+ "trafficLimitStrategy":
+ self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
+ }
+ if self.settings.parsed_default_panel_user_inbound_uuids:
+ panel_update_payload[
+ "activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
+ elif panel_user_created_now:
+ panel_update_payload["activateAllInbounds"] = True
- updated_panel_user = await self.panel_service.update_user_details_on_panel(
- panel_user_uuid, panel_update_payload)
- if not updated_panel_user:
- logging.warning(
- f"Panel user details update FAILED for trial user {panel_user_uuid}."
- )
- else:
- logging.info(
- f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
- )
- if updated_panel_user.get('subscriptionUrl'):
- panel_actual_subscription_url = updated_panel_user.get(
- 'subscriptionUrl')
- if updated_panel_user.get('shortUuid'):
- panel_short_uuid_for_link = updated_panel_user.get(
- 'shortUuid')
+ updated_panel_user = await self.panel_service.update_user_details_on_panel(
+ panel_user_uuid, panel_update_payload)
+ if not updated_panel_user or updated_panel_user.get("error"):
+ logging.warning(
+ f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}"
+ )
+ await session.rollback()
+ return {
+ "eligible": True,
+ "activated": False,
+ "message_key": "trial_activation_failed_panel_update"
+ }
- await db.commit()
- return {
- "eligible": True,
- "activated": True,
- "end_date": end_date,
- "days": self.settings.TRIAL_DURATION_DAYS,
- "traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
- "panel_user_uuid": panel_user_uuid,
- "panel_short_uuid": panel_short_uuid_for_link,
- "subscription_url": panel_actual_subscription_url
- }
- except Exception as e:
- logging.error(
- f"Error activating trial for user {user_id}: {e}",
- exc_info=True)
- await db.rollback()
- return {
- "eligible": True,
- "activated": False,
- "message_key": "trial_activation_failed"
- }
+ await session.commit()
+
+ final_subscription_url = updated_panel_user.get('subscriptionUrl')
+ final_panel_short_uuid = updated_panel_user.get(
+ 'shortUuid', panel_short_uuid)
+
+ return {
+ "eligible": True,
+ "activated": True,
+ "end_date": end_date,
+ "days": self.settings.TRIAL_DURATION_DAYS,
+ "traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
+ "panel_user_uuid": panel_user_uuid,
+ "panel_short_uuid": final_panel_short_uuid,
+ "subscription_url": final_subscription_url
+ }
async def activate_subscription(
- self,
- user_id: int,
- months: int,
- payment_amount: float,
- payment_id_internal: int,
- db_conn: aiosqlite.Connection,
- promo_code_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
- db = db_conn
- try:
- panel_user_uuid = await self.get_panel_user_uuid(user_id,
- db_conn=db)
- panel_subscription_uuid_for_link = None
- panel_short_uuid_for_link = None
- panel_user_interacted_now = False
- panel_actual_subscription_url: Optional[str] = None
- specific_inbounds_from_settings = self.settings.parsed_default_panel_user_inbound_uuids
- if not panel_user_uuid:
- panel_username_to_create = f"tg_{user_id}"
- creation_response = await self.panel_service.create_panel_user(
- username=panel_username_to_create,
- telegram_id=user_id,
- default_expire_days=self.settings.
- PANEL_USER_DEFAULT_EXPIRE_DAYS,
- default_traffic_limit_bytes=self.settings.
- PANEL_USER_DEFAULT_TRAFFIC_BYTES,
- default_traffic_limit_strategy=self.settings.
- PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
- specific_inbound_uuids=specific_inbounds_from_settings,
- activate_all_inbounds_default_flag=False
- if specific_inbounds_from_settings else True)
- panel_user_object_from_api = None
- if creation_response and not creation_response.get("error"):
- panel_user_object_from_api = creation_response.get(
- "response")
- elif creation_response and creation_response.get(
- "errorCode") == "A019":
- existing_users_list = await self.panel_service.get_users_by_filter(
- username=panel_username_to_create)
- if existing_users_list and len(existing_users_list) == 1:
- panel_user_object_from_api = existing_users_list[0]
- if panel_user_object_from_api and panel_user_object_from_api.get(
- 'uuid'):
- panel_user_uuid = panel_user_object_from_api['uuid']
- panel_subscription_uuid_for_link = panel_user_object_from_api.get(
- 'subscriptionUuid')
- panel_short_uuid_for_link = panel_user_object_from_api.get(
- 'shortUuid')
- panel_actual_subscription_url = panel_user_object_from_api.get(
- 'subscriptionUrl')
- cursor_conflict = await db.execute(
- "SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",
- (panel_user_uuid, user_id))
- conflicting_tg_user = await cursor_conflict.fetchone()
- await cursor_conflict.close()
- if conflicting_tg_user:
- logging.error(
- f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}."
- )
- return None
- await db.execute(
- "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
- (panel_user_uuid, user_id))
- panel_user_interacted_now = True
- if panel_user_object_from_api.get('telegramId') != user_id:
- await self.panel_service.update_user_details_on_panel(
- panel_user_uuid, {"telegramId": user_id})
- else:
- logging.error(
- f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}"
- )
- return None
- else:
- panel_user_data = await self.panel_service.get_user_by_uuid(
- panel_user_uuid)
- if panel_user_data:
- panel_subscription_uuid_for_link = panel_user_data.get(
- 'subscriptionUuid')
- panel_short_uuid_for_link = panel_user_data.get(
- 'shortUuid')
- panel_actual_subscription_url = panel_user_data.get(
- 'subscriptionUrl')
- if not panel_subscription_uuid_for_link and panel_short_uuid_for_link:
- panel_subscription_uuid_for_link = panel_short_uuid_for_link
- if not panel_subscription_uuid_for_link:
- logging.error(
- f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}."
- )
- return None
+ self,
+ session: AsyncSession,
+ user_id: int,
+ months: int,
+ payment_amount: float,
+ payment_db_id: int,
+ promo_code_id_from_payment: Optional[int] = None
+ ) -> Optional[Dict[str, Any]]:
- cursor = await db.execute(
- "SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",
- (panel_user_uuid, ))
- current_sub_row = await cursor.fetchone()
- await cursor.close()
- current_sub_end_date_str = current_sub_row[
- 'end_date'] if current_sub_row else None
- start_date = datetime.now(timezone.utc)
- if current_sub_end_date_str:
- try:
- parsed_current_end_date = datetime.fromisoformat(
- current_sub_end_date_str.replace("Z", "+00:00"))
- if parsed_current_end_date.tzinfo is None:
- parsed_current_end_date = parsed_current_end_date.replace(
- tzinfo=timezone.utc)
- if parsed_current_end_date > start_date:
- start_date = parsed_current_end_date
- except ValueError:
- logging.warning(
- f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}."
- )
- final_end_date = start_date + timedelta(days=months * 30)
- if promo_code_id:
- promo_cursor = await db.execute(
- "SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",
- (promo_code_id, ))
- promo_row = await promo_cursor.fetchone()
- await promo_cursor.close()
- if promo_row:
- final_end_date += timedelta(days=promo_row['bonus_days'])
- await db.execute(
- "INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",
- (promo_code_id, user_id, payment_id_internal))
- await db.execute(
- "UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",
- (promo_code_id, ))
-
- await db.execute(
- "UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",
- (panel_user_uuid, ))
- upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
- params = (user_id, panel_user_uuid,
- panel_subscription_uuid_for_link, start_date.isoformat(),
- final_end_date.isoformat(), months)
- upsert_cursor = await db.execute(upsert_sql, params)
- subscription_id_to_return = upsert_cursor.lastrowid
- if not subscription_id_to_return or subscription_id_to_return == 0:
- id_cursor = await db.execute(
- "SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",
- (panel_subscription_uuid_for_link, ))
- id_row = await id_cursor.fetchone()
- await id_cursor.close()
- if id_row:
- subscription_id_to_return = id_row['subscription_id']
- logging.info(
- f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}."
- )
-
- panel_update_payload: Dict[str, Any] = {
- "uuid":
- panel_user_uuid,
- "expireAt":
- final_end_date.isoformat(timespec='milliseconds').replace(
- '+00:00', 'Z'),
- "status":
- "ACTIVE",
- "trafficLimitBytes":
- self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
- "trafficLimitStrategy":
- self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
- }
- if specific_inbounds_from_settings:
- panel_update_payload[
- "activeUserInbounds"] = specific_inbounds_from_settings
- elif panel_user_interacted_now:
- panel_update_payload["activateAllInbounds"] = True
- if "activateAllInbounds" in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now:
- del panel_update_payload["activateAllInbounds"]
- logging.info(
- f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}"
- )
- updated_panel_user = await self.panel_service.update_user_details_on_panel(
- panel_user_uuid, panel_update_payload)
- if not updated_panel_user:
- logging.warning(
- f"Panel user details update FAILED for {panel_user_uuid}.")
- else:
- logging.info(
- f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}"
- )
- if updated_panel_user.get('subscriptionUrl'):
- panel_actual_subscription_url = updated_panel_user.get(
- 'subscriptionUrl')
- if updated_panel_user.get('shortUuid'):
- panel_short_uuid_for_link = updated_panel_user.get(
- 'shortUuid')
- return {
- "subscription_id": subscription_id_to_return,
- "end_date": final_end_date,
- "is_active": True,
- "panel_user_uuid": panel_user_uuid,
- "panel_short_uuid": panel_short_uuid_for_link,
- "subscription_url": panel_actual_subscription_url
- }
- except Exception as e:
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user:
logging.error(
- f"Error in activate_subscription (paid) for user {user_id}: {e}",
+ f"User {user_id} not found in DB for paid subscription activation."
+ )
+ return None
+
+ panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
+ session, user_id, db_user)
+
+ if not panel_user_uuid or not panel_sub_link_id:
+ logging.error(
+ f"Failed to ensure panel user for TG {user_id} during paid subscription."
+ )
+ return None
+
+ current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, user_id, panel_user_uuid)
+ start_date = datetime.now(timezone.utc)
+ if current_active_sub and current_active_sub.end_date and current_active_sub.end_date > start_date:
+ start_date = current_active_sub.end_date
+
+ duration_days_total = months * 30
+ applied_promo_bonus_days = 0
+
+ if promo_code_id_from_payment:
+ promo_model = await promo_code_dal.get_promo_code_by_id(
+ session, promo_code_id_from_payment)
+ if promo_model and promo_model.is_active and promo_model.current_activations < promo_model.max_activations:
+ applied_promo_bonus_days = promo_model.bonus_days
+ duration_days_total += applied_promo_bonus_days
+
+ activation = await promo_code_dal.record_promo_activation(
+ session,
+ promo_code_id_from_payment,
+ user_id,
+ payment_id=payment_db_id)
+ if activation:
+ await promo_code_dal.increment_promo_code_usage(
+ session, promo_code_id_from_payment)
+ else:
+ logging.warning(
+ f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}."
+ )
+ else:
+ logging.warning(
+ f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid."
+ )
+ promo_code_id_from_payment = None
+
+ final_end_date = start_date + timedelta(days=duration_days_total)
+ await subscription_dal.deactivate_other_active_subscriptions(
+ session, panel_user_uuid, panel_sub_link_id)
+
+ sub_payload = {
+ "user_id": user_id,
+ "panel_user_uuid": panel_user_uuid,
+ "panel_subscription_uuid": panel_sub_link_id,
+ "start_date": start_date,
+ "end_date": final_end_date,
+ "duration_months": months,
+ "is_active": True,
+ "status_from_panel": "ACTIVE",
+ "traffic_limit_bytes":
+ self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
+ }
+ try:
+ new_or_updated_sub = await subscription_dal.upsert_subscription(
+ session, sub_payload)
+ except Exception as e_upsert_sub:
+ logging.error(
+ f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
exc_info=True)
return None
+ panel_update_payload = {
+ "uuid":
+ panel_user_uuid,
+ "expireAt":
+ final_end_date.isoformat(timespec='milliseconds').replace(
+ '+00:00', 'Z'),
+ "status":
+ "ACTIVE",
+ "trafficLimitBytes":
+ self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
+ "trafficLimitStrategy":
+ self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
+ }
+ if self.settings.parsed_default_panel_user_inbound_uuids:
+ panel_update_payload[
+ "activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
+ elif panel_user_created_now:
+ panel_update_payload["activateAllInbounds"] = True
+
+ updated_panel_user = await self.panel_service.update_user_details_on_panel(
+ panel_user_uuid, panel_update_payload)
+ if not updated_panel_user or updated_panel_user.get("error"):
+ logging.warning(
+ f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}"
+ )
+ return None
+
+ final_subscription_url = updated_panel_user.get('subscriptionUrl')
+ final_panel_short_uuid = updated_panel_user.get(
+ 'shortUuid', panel_short_uuid)
+
+ return {
+ "subscription_id": new_or_updated_sub.subscription_id,
+ "end_date": final_end_date,
+ "is_active": True,
+ "panel_user_uuid": panel_user_uuid,
+ "panel_short_uuid": final_panel_short_uuid,
+ "subscription_url": final_subscription_url,
+ "applied_promo_bonus_days": applied_promo_bonus_days
+ }
+
async def extend_active_subscription_days(
self,
+ session: AsyncSession,
user_id: int,
bonus_days: int,
- db_conn: aiosqlite.Connection,
reason: str = "bonus") -> Optional[datetime]:
- db = db_conn
- try:
- user_cursor = await db.execute(
- "SELECT panel_user_uuid FROM users WHERE user_id = ?",
- (user_id, ))
- user_panel_data = await user_cursor.fetchone()
- if user_cursor: await user_cursor.close()
- panel_user_uuid_for_update = user_panel_data[
- 'panel_user_uuid'] if user_panel_data and 'panel_user_uuid' in user_panel_data.keys(
- ) and user_panel_data['panel_user_uuid'] else None
-
- sql_select_active_sub = "SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
- params_select_active_sub: tuple = (user_id, )
- if panel_user_uuid_for_update:
- sql_select_active_sub += "AND panel_user_uuid = ? "
- params_select_active_sub += (panel_user_uuid_for_update, )
- else:
- logging.warning(
- f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical)."
- )
- sql_select_active_sub += "ORDER BY end_date DESC LIMIT 1"
-
- cursor = await db.execute(sql_select_active_sub,
- params_select_active_sub)
- active_sub_row = await cursor.fetchone()
- if cursor: await cursor.close()
-
- if not active_sub_row or not active_sub_row['end_date']:
- logging.info(
- f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus."
- )
- return None
-
- current_end_date_str = active_sub_row['end_date']
- try:
- current_end_date = datetime.fromisoformat(
- current_end_date_str.replace("Z", "+00:00")) if isinstance(
- current_end_date_str, str) else current_end_date_str
- if not isinstance(current_end_date, datetime):
- raise ValueError(
- "current_end_date is not a datetime object after parsing"
- )
- except ValueError as ve:
- logging.error(
- f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}"
- )
- return None
-
- if current_end_date.tzinfo is None:
- current_end_date = current_end_date.replace(
- tzinfo=timezone.utc)
-
- now_utc = datetime.now(timezone.utc)
- start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
- new_end_date = start_point_for_bonus + timedelta(days=bonus_days)
-
- await db.execute(
- "UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",
- (new_end_date.isoformat(), active_sub_row['subscription_id']))
- logging.info(
- f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}"
+ user = await user_dal.get_user_by_id(session, user_id)
+ if not user or not user.panel_user_uuid:
+ logging.warning(
+ f"Cannot extend subscription for user {user_id}: User or panel_user_uuid not found."
)
+ return None
- if panel_user_uuid_for_update:
- panel_update_payload = {
- "uuid":
- panel_user_uuid_for_update,
+ active_sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, user_id, user.panel_user_uuid)
+ if not active_sub or not active_sub.end_date:
+ logging.info(
+ f"No active extendable subscription found for user {user_id} (panel: {user.panel_user_uuid}) for reason: {reason}."
+ )
+ return None
+
+ current_end_date = active_sub.end_date
+ now_utc = datetime.now(timezone.utc)
+ start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
+ new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
+
+ updated_sub_model = await subscription_dal.update_subscription_end_date(
+ session, active_sub.subscription_id, new_end_date_obj)
+
+ if updated_sub_model:
+ panel_update_success = await self.panel_service.update_user_details_on_panel(
+ user.panel_user_uuid, {
"expireAt":
- new_end_date.isoformat(timespec='milliseconds').replace(
- '+00:00', 'Z')
- }
- if not await self.panel_service.update_user_details_on_panel(
- panel_user_uuid_for_update, panel_update_payload):
- logging.warning(
- f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus."
- )
- return new_end_date
- except Exception as e:
+ new_end_date_obj.isoformat(
+ timespec='milliseconds').replace('+00:00', 'Z')
+ })
+ if not panel_update_success:
+ logging.warning(
+ f"Panel expiry update failed for {user.panel_user_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}."
+ )
+
+ logging.info(
+ f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}."
+ )
+ return new_end_date_obj
+ else:
logging.error(
- f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",
- exc_info=True)
+ f"Failed to update subscription end date locally for user {user_id}."
+ )
return None
- async def extend_subscription_for_referral(
- self,
- user_id: int,
- bonus_days: int,
- db_conn: aiosqlite.Connection,
- is_referee_bonus: bool = False) -> Optional[datetime]:
- reason = "referee bonus" if is_referee_bonus else "inviter referral bonus"
- return await self.extend_active_subscription_days(user_id,
- bonus_days,
- db_conn,
- reason=reason)
-
- async def get_active_subscription(
- self, user_id: int) -> Optional[Dict[str, Any]]:
- db, should_manage = await self._get_db()
- try:
- now_iso_utc = datetime.now(timezone.utc).isoformat()
- cursor = await db.execute(
- """SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",
- (user_id, now_iso_utc))
- sub_row = await cursor.fetchone()
- await cursor.close()
- if sub_row:
- sub_dict = dict(sub_row)
- for date_key in ['start_date', 'end_date']:
- if sub_dict.get(date_key) and isinstance(
- sub_dict[date_key], str):
- try:
- sub_dict[date_key] = datetime.fromisoformat(
- sub_dict[date_key].replace("Z", "+00:00"))
- except ValueError:
- logging.warning(
- f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription"
- )
- return sub_dict
+ async def get_active_subscription_details(
+ self, session: AsyncSession,
+ user_id: int) -> Optional[Dict[str, Any]]:
+ db_user = await user_dal.get_user_by_id(session, user_id)
+ if not db_user or not db_user.panel_user_uuid:
+ logging.info(
+ f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'."
+ )
return None
- finally:
- await self._release_db(db, should_manage)
+
+ panel_user_uuid = db_user.panel_user_uuid
+ local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
+ session, user_id, panel_user_uuid)
+ panel_user_data = await self.panel_service.get_user_by_uuid(
+ panel_user_uuid)
+
+ if not panel_user_data:
+ logging.warning(
+ f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Using local data if available."
+ )
+ if local_active_sub and local_active_sub.end_date and local_active_sub.end_date > datetime.now(
+ timezone.utc):
+ return {
+ "end_date":
+ local_active_sub.end_date,
+ "status_from_panel":
+ (local_active_sub.status_from_panel or "UNKNOWN") +
+ " (Panel Unreachable)",
+ "config_link":
+ await self.panel_service.get_subscription_link(
+ local_active_sub.panel_subscription_uuid)
+ if local_active_sub.panel_subscription_uuid else "N/A",
+ "traffic_limit_bytes":
+ local_active_sub.traffic_limit_bytes,
+ "traffic_used_bytes":
+ local_active_sub.traffic_used_bytes,
+ "user_bot_username":
+ db_user.username,
+ "is_panel_data":
+ False
+ }
+ return None
+
+ if local_active_sub:
+ update_payload_local = {}
+ panel_status = panel_user_data.get('status', 'UNKNOWN').upper()
+ panel_expire_at_str = panel_user_data.get('expireAt')
+ panel_traffic_used = panel_user_data.get('usedTrafficBytes')
+ panel_traffic_limit = panel_user_data.get('trafficLimitBytes')
+ panel_sub_uuid_from_panel = panel_user_data.get(
+ 'subscriptionUuid') or panel_user_data.get('shortUuid')
+
+ if local_active_sub.status_from_panel != panel_status:
+ update_payload_local['status_from_panel'] = panel_status
+ if panel_expire_at_str:
+ panel_expire_dt = datetime.fromisoformat(
+ panel_expire_at_str.replace("Z", "+00:00"))
+ if local_active_sub.end_date.replace(
+ microsecond=0) != panel_expire_dt.replace(
+ microsecond=0):
+ update_payload_local['end_date'] = panel_expire_dt
+ update_payload_local['last_notification_sent'] = None
+ if panel_traffic_used is not None and local_active_sub.traffic_used_bytes != panel_traffic_used:
+ update_payload_local['traffic_used_bytes'] = panel_traffic_used
+ if panel_traffic_limit is not None and local_active_sub.traffic_limit_bytes != panel_traffic_limit:
+ update_payload_local[
+ 'traffic_limit_bytes'] = panel_traffic_limit
+ if panel_sub_uuid_from_panel and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel:
+ update_payload_local[
+ 'panel_subscription_uuid'] = panel_sub_uuid_from_panel
+
+ is_active_based_on_panel = panel_status == "ACTIVE" and (
+ panel_expire_dt > datetime.now(timezone.utc)
+ if panel_expire_dt else False)
+ if local_active_sub.is_active != is_active_based_on_panel:
+ update_payload_local['is_active'] = is_active_based_on_panel
+
+ if update_payload_local:
+ await subscription_dal.update_subscription(
+ session, local_active_sub.subscription_id,
+ update_payload_local)
+
+ panel_end_date = datetime.fromisoformat(
+ panel_user_data['expireAt'].replace(
+ "Z", "+00:00")) if panel_user_data.get('expireAt') else None
+
+ return {
+ "end_date":
+ panel_end_date,
+ "status_from_panel":
+ panel_user_data.get('status', 'UNKNOWN').upper(),
+ "config_link":
+ panel_user_data.get('subscriptionUrl')
+ or (await self.panel_service.get_subscription_link(
+ panel_user_data['shortUuid'])
+ if panel_user_data.get('shortUuid') else "N/A"),
+ "traffic_limit_bytes":
+ panel_user_data.get('trafficLimitBytes'),
+ "traffic_used_bytes":
+ panel_user_data.get('usedTrafficBytes'),
+ "user_bot_username":
+ db_user.username,
+ "is_panel_data":
+ True
+ }
async def get_subscriptions_ending_soon(
- self, days_threshold: int) -> List[Dict[str, Any]]:
- db, should_manage = await self._get_db()
- try:
- now_utc = datetime.now(timezone.utc)
- threshold_date = now_utc + timedelta(days=days_threshold)
- today_date_str = now_utc.strftime('%Y-%m-%d')
- query = """ SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
- cursor = await db.execute(
- query, (now_utc.isoformat(), threshold_date.isoformat(),
- today_date_str))
- rows = await cursor.fetchall()
- await cursor.close()
- processed_rows = []
- for row_data in rows:
- row_dict = dict(row_data)
- end_date_obj_for_calc = None
- if isinstance(row_dict['end_date_raw'], str):
- try:
- end_date_obj_for_calc = datetime.fromisoformat(
- row_dict['end_date_raw'].replace("Z", "+00:00"))
- if end_date_obj_for_calc.tzinfo is None:
- end_date_obj_for_calc = end_date_obj_for_calc.replace(
- tzinfo=timezone.utc)
- row_dict['days_left'] = (end_date_obj_for_calc -
- now_utc).total_seconds() / (
- 24 * 3600)
- except ValueError:
- row_dict['days_left'] = None
- else:
- row_dict['days_left'] = None
- processed_rows.append(row_dict)
- return processed_rows
- finally:
- await self._release_db(db, should_manage)
+ self, session: AsyncSession,
+ days_threshold: int) -> List[Dict[str, Any]]:
+ subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
+ session, days_threshold)
+ results = []
+ for sub_model in subs_models_with_users:
+ if sub_model.user and sub_model.end_date:
+ days_left = (sub_model.end_date - datetime.now(
+ timezone.utc)).total_seconds() / (24 * 3600)
+ results.append({
+ 'user_id':
+ sub_model.user_id,
+ 'first_name':
+ sub_model.user.first_name or f"User {sub_model.user_id}",
+ 'language_code':
+ sub_model.user.language_code
+ or self.settings.DEFAULT_LANGUAGE,
+ 'end_date_str':
+ sub_model.end_date.strftime('%Y-%m-%d'),
+ 'days_left':
+ max(0, int(round(days_left))),
+ 'subscription_end_date_iso_for_update':
+ sub_model.end_date
+ })
+ return results
- async def update_last_notification_sent(self, user_id: int,
- subscription_end_date_iso: str):
- db, should_manage = await self._get_db()
- try:
- today_iso_date_str = datetime.now(
- timezone.utc).strftime('%Y-%m-%d')
- await db.execute(
- "UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",
- (today_iso_date_str, user_id, subscription_end_date_iso))
- await db.commit()
- except Exception as e:
- logging.error(
- f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}"
+ async def update_last_notification_sent(self, session: AsyncSession,
+ user_id: int,
+ subscription_end_date: datetime):
+ sub_to_update = await subscription_dal.find_subscription_for_notification_update(
+ session, user_id, subscription_end_date)
+ if sub_to_update:
+ await subscription_dal.update_subscription_notification_time(
+ session, sub_to_update.subscription_id,
+ datetime.now(timezone.utc))
+ logging.info(
+ f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}"
+ )
+ else:
+ logging.warning(
+ f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
)
- await db.rollback()
- finally:
- await self._release_db(db, should_manage)
diff --git a/config/settings.py b/config/settings.py
index 04699a3..a00a948 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -1,6 +1,6 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
-from pydantic import Field, field_validator, ValidationError
+from pydantic import Field, ValidationError, computed_field
from typing import Optional, List, Dict, Any
@@ -11,64 +11,67 @@ class Settings(BaseSettings):
alias="ADMIN_IDS",
description="Comma-separated list of admin Telegram User IDs")
- DB_NAME: str = Field(default="bot_database.sqlite3")
+ POSTGRES_USER: str = Field(default="user")
+ POSTGRES_PASSWORD: str = Field(default="password")
+ POSTGRES_HOST: str = Field(default="localhost")
+ POSTGRES_PORT: int = Field(default=5432)
+ POSTGRES_DB: str = Field(default="vpn_shop_db")
+
DEFAULT_LANGUAGE: str = Field(default="ru")
DEFAULT_CURRENCY_SYMBOL: str = Field(default="RUB")
- SUPPORT_LINK: Optional[str] = Field(
- default=None,
- description="Link to support contact (e.g., t.me/your_support_contact)"
- )
- SERVER_STATUS_URL: Optional[str] = Field(
- default=None,
- description="Link to server status page (e.g., Uptime Kuma)")
- TERMS_OF_SERVICE_URL: Optional[str] = Field(
- default=None, description="Link to Terms of Use page")
+ SUPPORT_LINK: Optional[str] = Field(default=None)
+ SERVER_STATUS_URL: Optional[str] = Field(default=None)
+ TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
- YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(
- default=None,
- description="Default email for YooKassa receipts (REQUIRED for 54-FZ)")
- YOOKASSA_VAT_CODE: int = Field(
- default=1, description="YooKassa VAT code (check YooKassa docs!)")
- YOOKASSA_PAYMENT_MODE: str = Field(
- default="full_prepayment",
- description=
- "YooKassa payment mode (e.g., full_prepayment, full_payment)")
- YOOKASSA_PAYMENT_SUBJECT: str = Field(
- default="service",
- description="YooKassa payment subject (e.g., service, commodity)")
+ YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
+ YOOKASSA_VAT_CODE: int = Field(default=1)
+ YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
+ YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
- PRICE_1_MONTH: Optional[int] = None
- PRICE_3_MONTHS: Optional[int] = None
- PRICE_6_MONTHS: Optional[int] = None
- PRICE_12_MONTHS: Optional[int] = None
+ PRICE_1_MONTH: Optional[int] = Field(default=None)
+ PRICE_3_MONTHS: Optional[int] = Field(default=None)
+ PRICE_6_MONTHS: Optional[int] = Field(default=None)
+ PRICE_12_MONTHS: Optional[int] = Field(default=None)
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0)
- REFERRAL_BONUS_DAYS_1_MONTH: Optional[int] = 3
- REFERRAL_BONUS_DAYS_3_MONTHS: Optional[int] = 7
- REFERRAL_BONUS_DAYS_6_MONTHS: Optional[int] = 15
- REFERRAL_BONUS_DAYS_12_MONTHS: Optional[int] = 30
- REFEREE_BONUS_DAYS_1_MONTH: Optional[int] = 1
- REFEREE_BONUS_DAYS_3_MONTHS: Optional[int] = 3
- REFEREE_BONUS_DAYS_6_MONTHS: Optional[int] = 7
- REFEREE_BONUS_DAYS_12_MONTHS: Optional[int] = 15
+ REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
+ default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
+ REFERRAL_BONUS_DAYS_INVITER_3_MONTHS: Optional[int] = Field(
+ default=7, alias="REFERRAL_BONUS_DAYS_3_MONTHS")
+ REFERRAL_BONUS_DAYS_INVITER_6_MONTHS: Optional[int] = Field(
+ default=15, alias="REFERRAL_BONUS_DAYS_6_MONTHS")
+ REFERRAL_BONUS_DAYS_INVITER_12_MONTHS: Optional[int] = Field(
+ default=30, alias="REFERRAL_BONUS_DAYS_12_MONTHS")
+
+ REFERRAL_BONUS_DAYS_REFEREE_1_MONTH: Optional[int] = Field(
+ default=1, alias="REFEREE_BONUS_DAYS_1_MONTH")
+ REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS: Optional[int] = Field(
+ default=3, alias="REFEREE_BONUS_DAYS_3_MONTHS")
+ REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS: Optional[int] = Field(
+ default=7, alias="REFEREE_BONUS_DAYS_6_MONTHS")
+ REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field(
+ default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS")
PANEL_API_URL: Optional[str] = None
PANEL_API_KEY: Optional[str] = None
PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1)
PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0)
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
- PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(default=None)
+ PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(
+ default=None,
+ description=
+ "Comma-separated UUIDs of inbounds to activate for new panel users")
TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3)
@@ -78,45 +81,42 @@ class Settings(BaseSettings):
WEB_SERVER_PORT: int = Field(default=8080)
LOGS_PAGE_SIZE: int = Field(default=10)
- _admin_ids_list: Optional[List[int]] = None
+ @computed_field
+ @property
+ def DATABASE_URL(self) -> str:
+ return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
+ @computed_field
@property
def ADMIN_IDS(self) -> List[int]:
- if self._admin_ids_list is None:
- if self.ADMIN_IDS_STR:
- try:
- self._admin_ids_list = [
- int(admin_id.strip())
- for admin_id in self.ADMIN_IDS_STR.split(',')
- if admin_id.strip().isdigit()
- ]
- if not self._admin_ids_list and self.ADMIN_IDS_STR:
- logging.error(
- f"ADMIN_IDS_STR ('{self.ADMIN_IDS_STR}') contains non-integer values or is malformed. No admin IDs loaded from string."
- )
- self._admin_ids_list = []
- except ValueError:
- logging.error(
- f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers."
- )
- self._admin_ids_list = []
- else:
- self._admin_ids_list = []
- return self._admin_ids_list
+ if self.ADMIN_IDS_STR:
+ try:
+ return [
+ int(admin_id.strip())
+ for admin_id in self.ADMIN_IDS_STR.split(',')
+ if admin_id.strip().isdigit()
+ ]
+ except ValueError:
+ logging.error(
+ f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers."
+ )
+ return []
+ return []
+ @computed_field
@property
def PRIMARY_ADMIN_ID(self) -> Optional[int]:
- admin_ids_list = self.ADMIN_IDS
- if admin_ids_list:
- return admin_ids_list[0]
- return None
+ ids = self.ADMIN_IDS
+ return ids[0] if ids else None
+ @computed_field
@property
def trial_traffic_limit_bytes(self) -> int:
if self.TRIAL_TRAFFIC_LIMIT_GB is None or self.TRIAL_TRAFFIC_LIMIT_GB <= 0:
return 0
return int(self.TRIAL_TRAFFIC_LIMIT_GB * (1024**3))
+ @computed_field
@property
def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]:
if self.PANEL_USER_DEFAULT_INBOUND_UUIDS:
@@ -127,49 +127,61 @@ class Settings(BaseSettings):
]
return None
+ @computed_field
@property
def yookassa_webhook_path(self) -> str:
+
return "/webhook/yookassa"
+ @computed_field
@property
def yookassa_full_webhook_url(self) -> Optional[str]:
if self.YOOKASSA_WEBHOOK_BASE_URL:
+
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
return None
+ @computed_field
@property
- def subscription_options(self) -> Dict[int, int]:
- options: Dict[int, int] = {}
- if self.PRICE_1_MONTH is not None: options[1] = self.PRICE_1_MONTH
- if self.PRICE_3_MONTHS is not None: options[3] = self.PRICE_3_MONTHS
- if self.PRICE_6_MONTHS is not None: options[6] = self.PRICE_6_MONTHS
- if self.PRICE_12_MONTHS is not None: options[12] = self.PRICE_12_MONTHS
+ def subscription_options(self) -> Dict[int, float]:
+ options: Dict[int, float] = {}
+
+ if self.PRICE_1_MONTH is not None:
+ options[1] = float(self.PRICE_1_MONTH / 100.0)
+ if self.PRICE_3_MONTHS is not None:
+ options[3] = float(self.PRICE_3_MONTHS / 100.0)
+ if self.PRICE_6_MONTHS is not None:
+ options[6] = float(self.PRICE_6_MONTHS / 100.0)
+ if self.PRICE_12_MONTHS is not None:
+ options[12] = float(self.PRICE_12_MONTHS / 100.0)
return options
+ @computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
bonuses: Dict[int, int] = {}
- if self.REFERRAL_BONUS_DAYS_1_MONTH is not None:
- bonuses[1] = self.REFERRAL_BONUS_DAYS_1_MONTH
- if self.REFERRAL_BONUS_DAYS_3_MONTHS is not None:
- bonuses[3] = self.REFERRAL_BONUS_DAYS_3_MONTHS
- if self.REFERRAL_BONUS_DAYS_6_MONTHS is not None:
- bonuses[6] = self.REFERRAL_BONUS_DAYS_6_MONTHS
- if self.REFERRAL_BONUS_DAYS_12_MONTHS is not None:
- bonuses[12] = self.REFERRAL_BONUS_DAYS_12_MONTHS
+ if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None:
+ bonuses[1] = self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH
+ if self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS is not None:
+ bonuses[3] = self.REFERRAL_BONUS_DAYS_INVITER_3_MONTHS
+ if self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS is not None:
+ bonuses[6] = self.REFERRAL_BONUS_DAYS_INVITER_6_MONTHS
+ if self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS is not None:
+ bonuses[12] = self.REFERRAL_BONUS_DAYS_INVITER_12_MONTHS
return bonuses
+ @computed_field
@property
def referral_bonus_referee(self) -> Dict[int, int]:
bonuses: Dict[int, int] = {}
- if self.REFEREE_BONUS_DAYS_1_MONTH is not None:
- bonuses[1] = self.REFEREE_BONUS_DAYS_1_MONTH
- if self.REFEREE_BONUS_DAYS_3_MONTHS is not None:
- bonuses[3] = self.REFEREE_BONUS_DAYS_3_MONTHS
- if self.REFEREE_BONUS_DAYS_6_MONTHS is not None:
- bonuses[6] = self.REFEREE_BONUS_DAYS_6_MONTHS
- if self.REFEREE_BONUS_DAYS_12_MONTHS is not None:
- bonuses[12] = self.REFEREE_BONUS_DAYS_12_MONTHS
+ if self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH is not None:
+ bonuses[1] = self.REFERRAL_BONUS_DAYS_REFEREE_1_MONTH
+ if self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS is not None:
+ bonuses[3] = self.REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS
+ if self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS is not None:
+ bonuses[6] = self.REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS
+ if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
+ bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
return bonuses
model_config = SettingsConfigDict(env_file='.env',
@@ -188,10 +200,23 @@ def get_settings() -> Settings:
_settings_instance = Settings()
if not _settings_instance.ADMIN_IDS:
logging.warning(
- "CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. Admin functionality will be restricted."
+ "CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. "
+ "Admin functionality will be restricted.")
+
+ if not _settings_instance.PANEL_API_URL:
+ logging.warning(
+ "CRITICAL: PANEL_API_URL is not set. Panel integration will not work."
)
+ if not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY:
+ logging.warning(
+ "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
+ )
+
except ValidationError as e:
logging.critical(
f"Pydantic validation error while loading settings: {e}")
- raise SystemExit(f"CRITICAL SETTINGS ERROR: {e}")
+
+ raise SystemExit(
+ f"CRITICAL SETTINGS ERROR: {e}. Please check your .env file and Settings model."
+ )
return _settings_instance
diff --git a/db/dal/message_log_dal.py b/db/dal/message_log_dal.py
new file mode 100644
index 0000000..576a1a3
--- /dev/null
+++ b/db/dal/message_log_dal.py
@@ -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
diff --git a/db/dal/panel_sync_dal.py b/db/dal/panel_sync_dal.py
new file mode 100644
index 0000000..a1ec684
--- /dev/null
+++ b/db/dal/panel_sync_dal.py
@@ -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
diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py
new file mode 100644
index 0000000..47cb147
--- /dev/null
+++ b/db/dal/payment_dal.py
@@ -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()
diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py
new file mode 100644
index 0000000..522d189
--- /dev/null
+++ b/db/dal/promo_code_dal.py
@@ -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
diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py
new file mode 100644
index 0000000..c621cfa
--- /dev/null
+++ b/db/dal/subscription_dal.py
@@ -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()
diff --git a/db/dal/user_dal.py b/db/dal/user_dal.py
new file mode 100644
index 0000000..526d4ef
--- /dev/null
+++ b/db/dal/user_dal.py
@@ -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,
+ }
diff --git a/db/database.py b/db/database.py
deleted file mode 100644
index 9a3f38a..0000000
--- a/db/database.py
+++ /dev/null
@@ -1,1081 +0,0 @@
-import aiosqlite
-import logging
-from typing import Optional, Dict, Any, List, Tuple
-from datetime import datetime, timezone
-
-from config.settings import get_settings
-
-try:
- settings = get_settings()
- DB_NAME = settings.DB_NAME
-except Exception as e:
- logging.critical(f"Could not load settings for database.py: {e}",
- exc_info=True)
- DB_NAME = "bot_database.sqlite3"
-
-DB_BUSY_TIMEOUT_SECONDS = 15.0
-
-
-def get_db_connection_manager():
- """
- Возвращает awaitable/async context manager для соединения с SQLite.
- """
- return aiosqlite.connect(DB_NAME, timeout=DB_BUSY_TIMEOUT_SECONDS)
-
-
-async def _setup_db_connection(db: aiosqlite.Connection):
- """Применяет необходимые PRAGMA и row_factory к установленному соединению."""
- try:
- await db.execute("PRAGMA journal_mode=WAL;")
- await db.execute(
- f"PRAGMA busy_timeout = {int(DB_BUSY_TIMEOUT_SECONDS * 1000)};")
- db.row_factory = aiosqlite.Row
- except Exception as e:
- logging.error(
- f"Failed to set PRAGMAs or row_factory on connection: {e}",
- exc_info=True)
-
-
-async def init_db():
- """Инициализирует схему БД."""
- try:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS users (
- user_id INTEGER PRIMARY KEY,
- username TEXT,
- first_name TEXT,
- last_name TEXT,
- language_code TEXT DEFAULT 'en',
- registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string, e.g. YYYY-MM-DD HH:MM:SS */
- is_banned INTEGER DEFAULT 0,
- panel_user_uuid TEXT UNIQUE,
- referred_by_id INTEGER,
- FOREIGN KEY (referred_by_id) REFERENCES users(user_id)
- )
- """)
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_users_panel_user_uuid ON users (panel_user_uuid);"
- )
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS subscriptions (
- subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- panel_user_uuid TEXT NOT NULL,
- panel_subscription_uuid TEXT UNIQUE,
- start_date TEXT, /* Storing as ISO TEXT */
- end_date TEXT NOT NULL, /* Storing as ISO TEXT */
- duration_months INTEGER,
- is_active INTEGER DEFAULT 1,
- status_from_panel TEXT,
- traffic_limit_bytes INTEGER,
- traffic_used_bytes INTEGER,
- last_notification_sent TEXT /* Storing as TEXT YYYY-MM-DD */
- )""")
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions (user_id);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_subscriptions_panel_user_uuid ON subscriptions (panel_user_uuid);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_subscriptions_end_date ON subscriptions (end_date);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_subscriptions_is_active ON subscriptions (is_active);"
- )
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS payments (
- payment_id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- yookassa_payment_id TEXT UNIQUE,
- idempotence_key TEXT UNIQUE,
- amount REAL NOT NULL,
- currency TEXT NOT NULL,
- status TEXT NOT NULL,
- description TEXT,
- subscription_duration_months INTEGER,
- promo_code_id INTEGER,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
- updated_at TIMESTAMP, /* TEXT as ISO8601 string */
- FOREIGN KEY (user_id) REFERENCES users(user_id),
- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id)
- )""")
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments (user_id);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_payments_yookassa_payment_id ON payments (yookassa_payment_id);"
- )
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS promo_codes (
- promo_code_id INTEGER PRIMARY KEY AUTOINCREMENT,
- code TEXT UNIQUE NOT NULL,
- bonus_days INTEGER NOT NULL,
- max_activations INTEGER NOT NULL,
- current_activations INTEGER DEFAULT 0,
- is_active INTEGER DEFAULT 1,
- created_by_admin_id INTEGER NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
- valid_until TEXT NULL /* Storing as ISO TEXT YYYY-MM-DD HH:MM:SS */
- )""")
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (code);"
- )
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS promo_code_activations (
- activation_id INTEGER PRIMARY KEY AUTOINCREMENT,
- promo_code_id INTEGER NOT NULL,
- user_id INTEGER NOT NULL,
- activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
- payment_id INTEGER,
- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id),
- FOREIGN KEY (user_id) REFERENCES users(user_id),
- FOREIGN KEY (payment_id) REFERENCES payments(payment_id),
- UNIQUE (promo_code_id, user_id)
- )""")
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS message_logs (
- log_id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- telegram_username TEXT,
- telegram_first_name TEXT,
- event_type TEXT NOT NULL,
- content TEXT,
- raw_update_preview TEXT,
- timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
- is_admin_event INTEGER DEFAULT 0,
- target_user_id INTEGER,
- FOREIGN KEY (user_id) REFERENCES users(user_id)
- )
- """)
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_message_logs_user_id ON message_logs (user_id);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_message_logs_event_type ON message_logs (event_type);"
- )
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_message_logs_timestamp ON message_logs (timestamp);"
- )
-
- await db.execute("""
- CREATE TABLE IF NOT EXISTS panel_sync_status (
- id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
- last_sync_time TIMESTAMP, /* TEXT as ISO8601 string */
- status TEXT,
- details TEXT,
- users_processed_from_panel INTEGER DEFAULT 0,
- subscriptions_synced INTEGER DEFAULT 0
- )""")
- await db.execute(
- "INSERT OR IGNORE INTO panel_sync_status (id, status, details) VALUES (1, 'never_run', 'System initialized')"
- )
-
- await db.commit()
- logging.info("Database initialized/checked successfully.")
- except Exception as e:
- logging.error(f"Failed to initialize database: {e}", exc_info=True)
- raise
-
-
-async def add_user_if_not_exists(
- user_id: int,
- username: Optional[str],
- first_name: Optional[str],
- last_name: Optional[str],
- lang_code: str = 'en',
- referred_by_id: Optional[int] = None,
- panel_user_uuid: Optional[str] = None) -> Tuple[bool, bool]:
- """Adds user if not exists. Sets referred_by_id for new users. Returns (success, was_new_user_flag)."""
- was_new_user = False
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- cursor = await db.execute(
- "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned FROM users WHERE user_id = ?",
- (user_id, ))
- existing_user = await cursor.fetchone()
- await cursor.close()
- if existing_user:
- update_fields = {}
- if username is not None and username != existing_user[
- 'username']:
- update_fields['username'] = username
- if first_name is not None and first_name != existing_user[
- 'first_name']:
- update_fields['first_name'] = first_name
- if last_name is not None and last_name != existing_user[
- 'last_name']:
- update_fields['last_name'] = last_name
-
- if panel_user_uuid and existing_user['panel_user_uuid'] is None:
- update_fields['panel_user_uuid'] = panel_user_uuid
-
- if lang_code != existing_user['language_code']:
- update_fields['language_code'] = lang_code
- if update_fields:
- set_clause = ", ".join(
- [f"{field} = ?" for field in update_fields.keys()])
- params = list(update_fields.values()) + [user_id]
- await db.execute(
- f"UPDATE users SET {set_clause} WHERE user_id = ?",
- tuple(params))
- else:
- await db.execute(
- """INSERT INTO users (user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned) VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
- (user_id, username, first_name, last_name, lang_code,
- referred_by_id, panel_user_uuid))
- logging.info(
- f"New user {user_id} added. Referred by: {referred_by_id or 'N/A'}."
- )
- was_new_user = True
- await db.commit()
- return True, was_new_user
- except aiosqlite.IntegrityError as e:
- if "UNIQUE constraint failed: users.panel_user_uuid" in str(
- e) and panel_user_uuid:
- res = await db.execute(
- "UPDATE users SET user_id = ?, username = ?, first_name = ?, last_name = ?, language_code = COALESCE(?, language_code) WHERE panel_user_uuid = ? AND user_id IS NULL",
- (user_id, username, first_name, last_name, lang_code,
- panel_user_uuid))
- if res.rowcount > 0:
- await db.commit()
- logging.info(
- f"Linked panel user {panel_user_uuid} to TG {user_id}."
- )
- return True, False
- else:
- logging.error(
- f"Conflict: Panel UUID {panel_user_uuid} exists.")
- await db.rollback()
- return False, False
- else:
- logging.error(f"DB integrity error for user {user_id}: {e}")
- await db.rollback()
- return False, False
- except Exception as e:
- logging.error(
- f"DB error in add_user_if_not_exists for user {user_id}: {e}",
- exc_info=True)
- await db.rollback()
- return False, False
-
-
-async def get_user(
- user_id: int,
- db_conn: Optional[aiosqlite.Connection] = None
-) -> Optional[aiosqlite.Row]:
- sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE user_id = ?"
- if db_conn:
- cursor = await db_conn.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
-
-
-async def get_user_by_telegram_username(
- username: str,
- db_conn: Optional[aiosqlite.Connection] = None
-) -> Optional[aiosqlite.Row]:
- sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE LOWER(username) = LOWER(?)"
- clean_username = username.lstrip('@')
- if db_conn:
- cursor = await db_conn.execute(sql, (clean_username, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (clean_username, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
-
-
-async def get_user_by_panel_uuid(
- panel_user_uuid: str,
- db_conn: Optional[aiosqlite.Connection] = None
-) -> Optional[aiosqlite.Row]:
- sql = "SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE panel_user_uuid = ?"
- if db_conn:
- cursor = await db_conn.execute(sql, (panel_user_uuid, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (panel_user_uuid, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
-
-
-async def get_banned_users_list_paginated(
- limit: int, offset: int) -> Tuple[List[aiosqlite.Row], int]:
- sql_users = "SELECT user_id, username, first_name, last_name FROM users WHERE is_banned = 1 ORDER BY registration_date DESC LIMIT ? OFFSET ?"
- sql_count = "SELECT COUNT(*) as total_banned FROM users WHERE is_banned = 1"
- users_list: List[aiosqlite.Row] = []
- total_banned = 0
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor_users = await db.execute(sql_users, (limit, offset))
- users_list = await cursor_users.fetchall()
- await cursor_users.close()
- cursor_count = await db.execute(sql_count)
- count_row = await cursor_count.fetchone()
- await cursor_count.close()
- if count_row: total_banned = count_row['total_banned']
- return users_list, total_banned
-
-
-async def get_user_active_subscription_end_date(
- user_id: int,
- db_conn: Optional[aiosqlite.Connection] = None) -> Optional[str]:
- sql = "SELECT strftime('%Y-%m-%d', end_date) as end_date_str FROM subscriptions WHERE user_id = ? AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime') ORDER BY end_date DESC LIMIT 1"
- if db_conn:
- cursor = await db_conn.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row['end_date_str'] if row else None
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row['end_date_str'] if row else None
-
-
-async def update_user_language_code(user_id: int, lang_code: str):
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await db.execute(
- "UPDATE users SET language_code = ? WHERE user_id = ?",
- (lang_code, user_id))
- await db.commit()
-
-
-async def set_user_ban_status_db(user_id: int, is_banned: bool) -> bool:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- await db.execute(
- "UPDATE users SET is_banned = ? WHERE user_id = ?",
- (1 if is_banned else 0, user_id))
- await db.commit()
- return True
- except Exception as e:
- logging.error(f"Error setting ban status for {user_id}: {e}")
- await db.rollback()
- return False
-
-
-async def update_user_panel_uuid(
- user_id: int,
- panel_user_uuid: str,
- db_conn: Optional[aiosqlite.Connection] = None):
-
- async def _operation(db_op: aiosqlite.Connection):
- try:
- await db_op.execute(
- "UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",
- (panel_user_uuid, user_id))
- await db_op.commit()
- except aiosqlite.IntegrityError:
- logging.error(
- f"Failed to update panel_uuid for user {user_id} (UNIQUE constraint)."
- )
- await db_op.rollback()
- except Exception as e:
- logging.error(
- f"Error in update_user_panel_uuid for {user_id}: {e}")
- await db_op.rollback()
-
- if db_conn:
- await _setup_db_connection(db_conn
- ) if not db_conn.row_factory else None
- await _operation(db_conn)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await _operation(db)
-
-
-async def update_or_create_user_from_panel(
- panel_data: Dict[str, Any],
- db_conn: Optional[aiosqlite.Connection] = None) -> bool:
- panel_uuid = panel_data.get('uuid')
- username = panel_data.get('username')
- if not panel_uuid:
- logging.warning(
- "update_or_create_user_from_panel: panel_uuid missing.")
- return False
-
- async def _operation(db_op: aiosqlite.Connection):
- existing_user = await get_user_by_panel_uuid(panel_uuid, db_conn=db_op)
- if not existing_user:
- reg_date_str = panel_data.get('createdAt',
- datetime.utcnow().isoformat())
- if isinstance(reg_date_str, datetime):
- reg_date_str = reg_date_str.isoformat()
- await db_op.execute(
- "INSERT INTO users (panel_user_uuid, username, registration_date, is_banned) VALUES (?, ?, ?, 0)",
- (panel_uuid, username, reg_date_str))
- await db_op.commit()
- return True
-
- try:
- if db_conn:
- await _setup_db_connection(db_conn
- ) if not db_conn.row_factory else None
- return await _operation(db_conn)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- return await _operation(db)
- except Exception as e:
- logging.error(
- f"Error in update_or_create_user_from_panel panel_uuid {panel_uuid}: {e}",
- exc_info=True)
- return False
-
-
-async def update_or_create_subscription_from_panel(
- panel_user_uuid: str,
- sub_data: Dict[str, Any],
- db_conn: Optional[aiosqlite.Connection] = None) -> bool:
-
- async def _operation(db_op: aiosqlite.Connection):
- cursor = await db_op.execute(
- "SELECT user_id FROM users WHERE panel_user_uuid = ?",
- (panel_user_uuid, ))
- user_row = await cursor.fetchone()
- await cursor.close()
- bot_user_id = user_row['user_id'] if user_row else None
- panel_sub_link_uuid = sub_data.get('subscriptionUuid') or sub_data.get(
- 'shortUuid')
- if not panel_sub_link_uuid:
- logging.warning(
- f"No panel_subscription_uuid or shortUuid for panel user {panel_user_uuid}"
- )
- return False
- end_date_str = sub_data.get('expireAt')
- end_date = None
- if end_date_str:
- try:
- end_date = datetime.fromisoformat(
- end_date_str.replace("Z", "+00:00"))
- except ValueError:
- logging.warning(
- f"Bad expireAt: {end_date_str} for {panel_user_uuid}")
- return False
- if not end_date:
- logging.warning(f"No end_date for {panel_user_uuid}")
- return False
- status_panel = sub_data.get('status', 'UNKNOWN').upper()
- is_active_panel = 1 if status_panel == 'ACTIVE' else 0
- traffic_limit = sub_data.get('trafficLimitBytes')
- traffic_used = sub_data.get('usedTrafficBytes',
- sub_data.get('lifetimeUsedTrafficBytes'))
- start_date_iso = sub_data.get('createdAt',
- datetime.utcnow().isoformat())
- if isinstance(start_date_iso, datetime):
- start_date_iso = start_date_iso.isoformat()
- duration_months_val = sub_data.get('duration_months')
- upsert_sql = """ INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = excluded.is_active, status_from_panel = excluded.status_from_panel, traffic_limit_bytes = excluded.traffic_limit_bytes, traffic_used_bytes = excluded.traffic_used_bytes, last_notification_sent = NULL; """
- params = (bot_user_id, panel_user_uuid, panel_sub_link_uuid,
- start_date_iso, end_date.isoformat(), duration_months_val,
- is_active_panel, status_panel, traffic_limit, traffic_used)
- await db_op.execute(upsert_sql, params)
- await db_op.commit()
- return True
-
- try:
- if db_conn:
- await _setup_db_connection(db_conn
- ) if not db_conn.row_factory else None
- return await _operation(db_conn)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- return await _operation(db)
- except Exception as e:
- logging.error(
- f"Error in update_or_create_subscription_from_panel for {panel_user_uuid}: {e}",
- exc_info=True)
- return False
-
-
-async def update_sync_status(status: str,
- details: str,
- users_processed: int = 0,
- subs_synced: int = 0):
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await db.execute(
- "UPDATE panel_sync_status SET last_sync_time=CURRENT_TIMESTAMP, status=?, details=?, users_processed_from_panel=?, subscriptions_synced=? WHERE id=1",
- (status, details, users_processed, subs_synced))
- await db.commit()
-
-
-async def get_last_sync_status() -> Optional[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- "SELECT * FROM panel_sync_status WHERE id = 1")
- row = await cursor.fetchone()
- await cursor.close()
- return row
-
-
-async def get_all_users_for_broadcast() -> List[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- "SELECT user_id FROM users WHERE user_id IS NOT NULL AND is_banned = 0"
- )
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
-
-
-async def get_user_count_stats() -> Dict[str, int]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- "SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL"
- )
- total_users_c_row = await cursor.fetchone()
- await cursor.close()
- cursor = await db.execute(
- "SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL AND is_banned = 1"
- )
- banned_users_c_row = await cursor.fetchone()
- await cursor.close()
- cursor = await db.execute(
- "SELECT COUNT(DISTINCT user_id) as count FROM subscriptions WHERE user_id IS NOT NULL AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime')"
- )
- active_subs_c_row = await cursor.fetchone()
- await cursor.close()
- return {
- "total_users":
- total_users_c_row['count'] if total_users_c_row else 0,
- "banned_users":
- banned_users_c_row['count'] if banned_users_c_row else 0,
- "users_with_active_subscriptions":
- active_subs_c_row['count'] if active_subs_c_row else 0
- }
-
-
-async def get_payment_logs(limit: int = 20,
- offset: int = 0) -> List[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- """SELECT p.payment_id, p.user_id, u.username, p.amount, p.currency, p.status, p.description, strftime('%Y-%m-%d %H:%M:%S', p.created_at) as created_at FROM payments p LEFT JOIN users u ON p.user_id = u.user_id ORDER BY p.created_at DESC LIMIT ? OFFSET ?""",
- (limit, offset))
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
-
-
-async def log_user_action(user_id: Optional[int],
- event_type: str,
- content: Optional[str] = None,
- telegram_username: Optional[str] = None,
- telegram_first_name: Optional[str] = None,
- is_admin_event: bool = False,
- target_user_id: Optional[int] = None,
- raw_update_preview: Optional[str] = None):
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- await db.execute(
- """INSERT INTO message_logs (user_id, telegram_username, telegram_first_name, event_type, content, raw_update_preview, is_admin_event, target_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
- (user_id, telegram_username, telegram_first_name, event_type,
- content, raw_update_preview, 1 if is_admin_event else 0,
- target_user_id))
- await db.commit()
- except Exception as e:
- logging.error(
- f"Failed to log action for user {user_id}, type {event_type}: {e}",
- exc_info=True)
-
-
-async def get_message_logs_db(limit: int = 20,
- offset: int = 0) -> List[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- """SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str, is_admin_event FROM message_logs ORDER BY timestamp DESC LIMIT ? OFFSET ?""",
- (limit, offset))
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
-
-
-async def count_all_message_logs() -> int:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute("SELECT COUNT(*) as count FROM message_logs")
- row = await cursor.fetchone()
- await cursor.close()
- return row['count'] if row else 0
-
-
-async def get_user_message_logs_paginated(user_id_to_search: int, limit: int,
- offset: int) -> List[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- """SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str FROM message_logs WHERE user_id = ? OR target_user_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?""",
- (user_id_to_search, user_id_to_search, limit, offset))
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
-
-
-async def count_user_message_logs(user_id_to_search: int) -> int:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- "SELECT COUNT(*) as count FROM message_logs WHERE user_id = ? OR target_user_id = ?",
- (user_id_to_search, user_id_to_search))
- row = await cursor.fetchone()
- await cursor.close()
- return row['count'] if row else 0
-
-
-async def add_payment_record(user_id: int,
- yookassa_payment_id: Optional[str],
- idempotence_key: Optional[str],
- amount: float,
- currency: str,
- status: str,
- description: str,
- sub_months: int,
- promo_id: Optional[int] = None) -> Optional[int]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- cursor = await db.execute(
- """INSERT INTO payments (user_id, yookassa_payment_id, idempotence_key, amount, currency, status, description, subscription_duration_months, promo_code_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
- (user_id, yookassa_payment_id, idempotence_key, amount,
- currency, status, description, sub_months, promo_id))
- await db.commit()
- return cursor.lastrowid
- except Exception as e:
- logging.error(
- f"Failed to add payment record user {user_id}, yk_id {yookassa_payment_id}: {e}"
- )
- await db.rollback()
- return None
-
-
-async def update_payment_status(
- payment_db_id: Optional[int] = None,
- yookassa_payment_id: Optional[str] = None,
- new_status: Optional[str] = None,
- db_conn: Optional[aiosqlite.Connection] = None):
-
- async def _operation(db_op: aiosqlite.Connection):
- if new_status is None: return
- if payment_db_id:
- await db_op.execute(
- "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ?",
- (new_status, payment_db_id))
- elif yookassa_payment_id:
- await db_op.execute(
- "UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE yookassa_payment_id = ?",
- (new_status, yookassa_payment_id))
- else:
- logging.warning(
- "update_payment_status called without payment_id or yookassa_payment_id"
- )
- return
- await db_op.commit()
-
- if db_conn:
- await _setup_db_connection(db_conn
- ) if not db_conn.row_factory else None
- await _operation(db_conn)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- await _operation(db)
-
-
-async def get_payment_by_yookassa_id(
- yookassa_payment_id: str) -> Optional[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(
- "SELECT * FROM payments WHERE yookassa_payment_id = ?",
- (yookassa_payment_id, ))
- row = await cursor.fetchone()
- await cursor.close()
- return row
-
-
-async def create_promo_code_db(
- code: str,
- bonus_days: int,
- max_activations: int,
- admin_id: int,
- valid_until_dt: Optional[datetime] = None) -> Optional[int]:
- valid_until_iso: Optional[str] = valid_until_dt.isoformat(
- ) if valid_until_dt else None
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- cursor = await db.execute(
- "INSERT INTO promo_codes (code, bonus_days, max_activations, created_by_admin_id, valid_until) VALUES (?, ?, ?, ?, ?)",
- (code.upper(), bonus_days, max_activations, admin_id,
- valid_until_iso))
- await db.commit()
- return cursor.lastrowid
- except aiosqlite.IntegrityError:
- logging.warning(f"Promo code {code.upper()} exists.")
- await db.rollback()
- return None
- except Exception as e:
- logging.error(f"Error creating promo {code.upper()}: {e}")
- await db.rollback()
- return None
-
-
-async def get_promo_codes_db(is_active_only: bool = True,
- limit: int = 20,
- offset: int = 0) -> List[aiosqlite.Row]:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- query = "SELECT promo_code_id, code, bonus_days, max_activations, current_activations, is_active, created_by_admin_id, created_at, valid_until FROM promo_codes"
- params = []
- conditions = []
- if is_active_only: conditions.append("is_active = 1")
- conditions.append(
- "(valid_until IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))"
- )
- if conditions: query += " WHERE " + " AND ".join(conditions)
- query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
- params.extend([limit, offset])
- cursor = await db.execute(query, tuple(params))
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
-
-
-async def get_promo_code_by_code(
- code: str,
- db_conn: Optional[aiosqlite.Connection] = None
-) -> Optional[aiosqlite.Row]:
- sql = "SELECT * FROM promo_codes WHERE code = ? AND is_active = 1 AND (DATETIME(valid_until) IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))"
- if db_conn:
- cursor = await db_conn.execute(sql, (code.upper(), ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (code.upper(), ))
- row = await cursor.fetchone()
- if cursor:
- await cursor.close()
- return row
-
-
-async def increment_promo_activation(promo_code_id: int,
- user_id: int,
- db_conn: aiosqlite.Connection,
- payment_id: Optional[int] = None) -> bool:
-
- db = db_conn
- try:
- cursor = await db.execute(
- "SELECT 1 FROM promo_code_activations WHERE promo_code_id = ? AND user_id = ?",
- (promo_code_id, user_id))
- existing = await cursor.fetchone()
- await cursor.close()
- if existing:
- logging.info(f"User {user_id} already used promo {promo_code_id}.")
- return False
- await db.execute(
- "INSERT INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",
- (promo_code_id, user_id, payment_id))
- await db.execute(
- "UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",
- (promo_code_id, ))
- return True
- except aiosqlite.IntegrityError:
- logging.warning(
- f"IntegrityError on promo activation p:{promo_code_id} u:{user_id}."
- )
- return False
- except Exception as e:
- logging.error(
- f"Error promo activation p:{promo_code_id} u:{user_id}: {e}",
- exc_info=True)
- return False
-
-
-async def get_all_message_logs_paginated(limit: int,
- offset: int) -> List[aiosqlite.Row]:
- """
- Fetches a paginated list of all message logs, ordered by the newest first.
- """
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
-
- sql = """
- SELECT
- log_id,
- user_id,
- telegram_username,
- telegram_first_name,
- event_type,
- content,
- strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str,
- is_admin_event,
- target_user_id
- FROM message_logs
- ORDER BY timestamp DESC
- LIMIT ? OFFSET ?
- """
- try:
- cursor = await db.execute(sql, (limit, offset))
- rows = await cursor.fetchall()
- await cursor.close()
- return rows
- except Exception as e:
- logging.error(f"Error fetching all message logs: {e}",
- exc_info=True)
- return []
-
-
-async def has_had_any_subscription(
- user_id: int, db_conn: Optional[aiosqlite.Connection] = None) -> bool:
- """Checks if a user has ever had any subscription (trial or paid)."""
- sql = "SELECT 1 FROM subscriptions WHERE user_id = ? LIMIT 1"
- if db_conn:
- cursor = await db_conn.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- await cursor.close()
- return row is not None
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- cursor = await db.execute(sql, (user_id, ))
- row = await cursor.fetchone()
- await cursor.close()
- return row is not None
-
-
-async def sync_panel_user_data(
- panel_user_dict: Dict[str, Any],
- db_conn_passed: Optional[aiosqlite.Connection] = None) -> bool:
- """
- Synchronizes a single panel user's data (user info and subscription) with the local bot database.
- Prioritizes matching/creating bot user by panel_user_dict['telegramId'].
- Manages its own transaction if db_conn_passed is None.
- """
- panel_uuid = panel_user_dict.get('uuid')
- telegram_id_from_panel = panel_user_dict.get('telegramId')
- panel_username = panel_user_dict.get('username')
-
- if not panel_uuid:
- logging.warning(
- f"Sync: Panel user data missing 'uuid'. Data: {panel_user_dict}")
- return False
- if not telegram_id_from_panel:
- logging.info(
- f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no 'telegramId'. Skipping TG ID based sync for this user."
- )
-
- return False
-
- async def _operation(db: aiosqlite.Connection):
-
- bot_user_id = int(telegram_id_from_panel)
-
- existing_bot_user = await get_user(bot_user_id, db_conn=db)
-
- if existing_bot_user:
-
- if existing_bot_user['panel_user_uuid'] != panel_uuid:
- if existing_bot_user['panel_user_uuid'] is not None:
- logging.warning(
- f"Sync: TG User {bot_user_id} already linked to panel_uuid {existing_bot_user['panel_user_uuid']}, but panel now provides {panel_uuid} for this TG ID. Updating to new panel_uuid."
- )
-
- cursor_conflict = await db.execute(
- "SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",
- (panel_uuid, bot_user_id))
- conflicting_user = await cursor_conflict.fetchone()
- await cursor_conflict.close()
- if conflicting_user:
- logging.error(
- f"Sync: CRITICAL CONFLICT! New panel_uuid {panel_uuid} (for TG ID {bot_user_id}) is already linked to different TG User {conflicting_user['user_id']}. Skipping user update for panel_uuid."
- )
- else:
- await db.execute(
- "UPDATE users SET panel_user_uuid = ?, username = ? WHERE user_id = ?",
- (panel_uuid, panel_username, bot_user_id))
- logging.info(
- f"Sync: Updated panel_uuid for existing TG user {bot_user_id} to {panel_uuid}."
- )
- else:
-
- if panel_username and existing_bot_user[
- 'username'] != panel_username:
- await db.execute(
- "UPDATE users SET username = ? WHERE user_id = ?",
- (panel_username, bot_user_id))
- logging.info(
- f"Sync: Updated username for TG user {bot_user_id} from panel username {panel_username}."
- )
- else:
-
- cursor_conflict = await db.execute(
- "SELECT user_id FROM users WHERE panel_user_uuid = ?",
- (panel_uuid, ))
- conflicting_user = await cursor_conflict.fetchone()
- await cursor_conflict.close()
- if conflicting_user:
- logging.error(
- f"Sync: CRITICAL CONFLICT! Panel UUID {panel_uuid} (for new TG ID {bot_user_id}) is already linked to existing TG User {conflicting_user['user_id']}. Skipping new user creation."
- )
- return False
-
- reg_date_str = panel_user_dict.get(
- 'createdAt',
- datetime.now(timezone.utc).isoformat())
- if isinstance(reg_date_str, datetime):
- reg_date_str = reg_date_str.isoformat()
- await db.execute(
- """INSERT INTO users (user_id, username, panel_user_uuid, registration_date, is_banned, language_code)
- VALUES (?, ?, ?, ?, 0, ?)""",
- (bot_user_id, panel_username, panel_uuid, reg_date_str,
- settings.DEFAULT_LANGUAGE))
- logging.info(
- f"Sync: New user created in bot DB from panel data: TG ID {bot_user_id}, Panel UUID {panel_uuid}, Panel Username {panel_username}"
- )
-
- panel_sub_link_uuid = panel_user_dict.get(
- 'subscriptionUuid') or panel_user_dict.get('shortUuid')
- if not panel_sub_link_uuid:
- logging.warning(
- f"Sync: Panel user {panel_uuid} (TG ID: {bot_user_id}) has no 'subscriptionUuid' or 'shortUuid'. Cannot sync subscription link."
- )
-
- await db.commit()
- return True
-
- end_date_str = panel_user_dict.get('expireAt')
- end_date_obj = None
- if end_date_str:
- try:
- end_date_obj = datetime.fromisoformat(
- end_date_str.replace("Z", "+00:00"))
- except ValueError:
- logging.warning(
- f"Sync: Bad expireAt '{end_date_str}' for panel user {panel_uuid}. Skipping subscription update."
- )
- return True
-
- if not end_date_obj:
- logging.warning(
- f"Sync: No valid end_date for panel user {panel_uuid}. Skipping subscription update."
- )
- return True
-
- status_panel = panel_user_dict.get('status', 'UNKNOWN').upper()
- is_active_panel = 1 if status_panel == 'ACTIVE' else 0
- traffic_limit = panel_user_dict.get('trafficLimitBytes')
- traffic_used = panel_user_dict.get(
- 'usedTrafficBytes',
- panel_user_dict.get('lifetimeUsedTrafficBytes'))
-
- start_date_iso = panel_user_dict.get(
- 'createdAt',
- datetime.now(timezone.utc).isoformat())
- if isinstance(start_date_iso, datetime):
- start_date_iso = start_date_iso.isoformat()
-
- duration_months_val = None
-
- await db.execute(
- "UPDATE subscriptions SET is_active = 0 WHERE user_id = ? AND panel_user_uuid = ? AND is_active = 1 AND panel_subscription_uuid != ?",
- (bot_user_id, panel_uuid, panel_sub_link_uuid))
-
- upsert_sub_sql = """
- INSERT INTO subscriptions (
- user_id, panel_user_uuid, panel_subscription_uuid,
- start_date, end_date, duration_months,
- is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes,
- last_notification_sent
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
- ON CONFLICT(panel_subscription_uuid) DO UPDATE SET
- user_id = excluded.user_id,
- panel_user_uuid = excluded.panel_user_uuid,
- start_date = excluded.start_date,
- end_date = excluded.end_date,
- duration_months = excluded.duration_months,
- is_active = excluded.is_active,
- status_from_panel = excluded.status_from_panel,
- traffic_limit_bytes = excluded.traffic_limit_bytes,
- traffic_used_bytes = excluded.traffic_used_bytes,
- last_notification_sent = NULL;
- """
- sub_params = (bot_user_id, panel_uuid, panel_sub_link_uuid,
- start_date_iso, end_date_obj.isoformat(),
- duration_months_val, is_active_panel, status_panel,
- traffic_limit, traffic_used)
- await db.execute(upsert_sub_sql, sub_params)
- logging.info(
- f"Sync: Subscription upserted for TG ID {bot_user_id}, Panel UUID {panel_uuid}, Link ID {panel_sub_link_uuid}"
- )
-
- await db.commit()
- return True
-
- if db_conn_passed:
- return await _operation(db_conn_passed)
- else:
- async with get_db_connection_manager() as db:
- await _setup_db_connection(db)
- try:
- return await _operation(db)
-
- except Exception as e:
- logging.error(
- f"Sync: General DB error during sync_panel_user_data for panel UUID {panel_uuid}: {e}",
- exc_info=True)
- await db.rollback()
- return False
diff --git a/db/database_setup.py b/db/database_setup.py
new file mode 100644
index 0000000..0b48c3f
--- /dev/null
+++ b/db/database_setup.py
@@ -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)
diff --git a/db/models.py b/db/models.py
new file mode 100644
index 0000000..72695f3
--- /dev/null
+++ b/db/models.py
@@ -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""
+
+
+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""
+
+
+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'), )
diff --git a/locales/ru.json b/locales/ru.json
index 2b32121..e36b1c3 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1,188 +1,205 @@
{
"welcome": "Добро пожаловать, {user_name}!",
- "choose_language": "Пожалуйста, выберите ваш язык:",
- "language_set": "Язык установлен на Русский.",
- "language_set_alert": "Язык обновлен!",
- "main_menu_greeting": "Привет, {user_name}!\nЧто бы вы хотели сделать?",
- "error_service_unavailable": "⚠️ Сервис временно недоступен. Попробуйте позже.",
- "error_occurred_processing_request": "⚠️ Произошла ошибка. Попробуйте еще раз.",
- "unknown_command_private": "Неизвестная команда. Используйте /start.",
- "error_try_again": "Произошла ошибка. Попробуйте снова.",
+ "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
- "menu_subscribe_inline": "🚀 Купить",
- "menu_my_subscription_inline": "📆 Моя подписка",
- "menu_referral_inline": "🎁 Рефералы",
- "menu_apply_promo_button": "🎁 Промокод",
"menu_activate_trial_button": "🆓 Пробный период",
+ "menu_subscribe_inline": "🚀 Купить",
+ "menu_my_subscription_inline": "🔐 Моя подписка",
+ "menu_referral_inline": "🎁 Рефералы",
+ "menu_apply_promo_button": "🎟 Промокод",
"menu_language_settings_inline": "🌐 Язык",
"menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса",
"back_to_main_menu_button": "⬅️ Назад",
+
+ "choose_language": "Выберите язык / Select language:",
+ "language_set_alert": "Язык изменен!",
+ "error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
+ "error_displaying_menu": "Ошибка отображения меню.",
+ "main_menu_unknown_action": "Неизвестное действие.",
- "no_subscription_options_available": "Варианты подписки сейчас недоступны.",
- "select_subscription_period": "Выберите период подписки:",
+ "select_subscription_period": "Выберите срок подписки:",
+ "no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
- "confirm_subscription_prompt": "Подписка на {months} мес. за {price} {currency_symbol}.\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": "✅ Оплата успешна! Ваша подписка на {months} мес. активна до {end_date}.",
- "payment_failed": "❌ Оплата не удалась/отменена. Если деньги списаны, напишите в поддержку.",
- "payment_description_subscription": "Подписка на {months} мес.",
- "my_subscription_details": "ℹ️ Ваша подписка\n\n🗓️ До: {end_date} (Дней осталось: {days_left})\n\n🔗 Ссылка: {config_link}\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": "🔐 Моя подписка:\n\nСтатус: {status}\nАктивна до: {end_date} (осталось дней: {days_left})\n\nТрафик: {traffic_used} из {traffic_limit}\n\nСсылка на конфигурацию:\n{config_link}",
+ "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Ваша подписка истекает {end_date} (дней осталось: {days_left}).\nНе забудьте продлить!",
- "payment_successful_with_referral_bonus": "✅ Оплата на {months} мес. успешна! Базовая дата: {base_end_date}.\n\n🎉 Вас пригласил(а) {inviter_name}, вы получили дополнительно {bonus_days} бонусных дней!\nИтоговая дата окончания: {final_end_date}.",
- "payment_successful_error_details": "Оплата прошла успешно, но возникла ошибка отображения деталей подписки. Пожалуйста, проверьте раздел 'Моя подписка' или свяжитесь с поддержкой.",
- "error_processing_your_payment": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.",
-
- "trial_confirm_prompt": "Активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb} ГБ?\n\nЭто можно сделать только один раз, если у вас ранее не было подписок.",
- "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": "🎉 Ваш бесплатный пробный период на {days} дней активирован!\nДействителен до: {end_date}.\nСсылка на конфигурацию: {config_link}\nТрафик: {traffic_gb}\nПриятного использования!",
+ "traffic_unlimited": "Безлимитный",
+ "traffic_na": "н/д",
+ "error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.",
+
+ "promo_code_prompt": "Пожалуйста, введите ваш промокод:",
+ "promo_code_not_found": "Промокод {code} не найден, истек или уже использован максимальное количество раз.",
+ "promo_code_already_used_by_user": "Вы уже активировали промокод {code}.",
+ "promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
+ "promo_code_applied_success": "✅ Промокод {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Действует до: {end_date}\nТрафик: {traffic_gb}\n\nВаша ссылка на конфигурацию:\n{config_link}",
+ "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{referral_link}\n\nБонусы:{bonus_details}",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.",
"no_bonus_days": "0",
"referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.",
"referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.",
- "referral_bonus_inviter_notification_extended": "🎉 Ура, {referee_name} (ваш реферал) подписался!\nВам начислено {days} бонусных дней.\nНовая дата окончания: {new_end_date}.",
"friend_placeholder": "друг",
-
- "error_user_not_synced_for_config": "Аккаунт не синхронизирован. Попробуйте /sync или поддержка.",
- "error_fetching_config_link_details": "Ошибка получения данных для ссылки. Попробуйте или поддержка.",
- "error_fetching_config_link_details_no_uuid": "ID для ссылки не найден. Поддержка.",
- "error_generating_config_link": "Не удалось создать ссылку. Попробуйте позже.",
+ "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
+ "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
- "promo_code_prompt": "Введите ваш промокод:",
- "promo_code_invalid_or_expired": "❌ Неверный, истекший или уже использованный промокод.",
- "promo_code_no_active_subscription": "❌ Для применения этого промокода у вас должна быть активная подписка.",
- "promo_code_applied_success": "✅ Промокод '{code}' применен! {bonus_days} бонусных дней добавлено к вашей подписке.\nТеперь ваша подписка активна до: {new_end_date}.",
- "promo_code_max_activations_reached": "❌ К сожалению, промокод '{code}' достиг максимального числа активаций.",
- "promo_code_not_found": "❌ Промокод '{code}' не найден.",
- "error_applying_promo_bonus": "⚠️ Произошла ошибка при применении промо-бонуса. Попробуйте или поддержка.",
- "promo_input_cancelled_no_message": "Ввод промокода отменен.",
+ "user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
- "user_is_banned": "⚠️ Ваш доступ к боту заблокирован.\nЕсли это ошибка, обратитесь в поддержку.",
- "contact_support_button": "💬 Поддержка",
- "suspicious_input_ban_message": "⚠️ Обнаружен подозрительный ввод. В целях безопасности ваш доступ к боту ограничен. Обратитесь в поддержку, если это ошибка.",
-
- "admin_panel_title": "👑 Админ-панель",
- "admin_panel_button_reply": "👑 Админ-панель",
- "back_to_admin_panel_button": "⬅️ В админку",
- "admin_unknown_action": "Неизвестное действие.",
- "admin_action_cancelled_default": "Действие отменено.",
- "prev_page_button": "Пред.",
- "next_page_button": "След.",
- "yes_button": "✅ Да",
- "no_button": "❌ Нет",
-
- "admin_ban_user_button": "🚫 Бан",
- "admin_unban_user_button": "✅ Разбан",
- "admin_view_banned_users_button": "📜 Бан-лист",
- "admin_ban_user_prompt": "ID или @username для бана:",
- "admin_unban_user_prompt": "ID или @username для разбана:",
- "admin_invalid_user_id_format": "Неверный ID. Нужно число.",
- "admin_invalid_username_format": "Неверный username. Формат: @юзернейм (5-32 симв.).",
- "admin_user_not_found_by_username": "Юзер @{username} не найден в боте.",
- "admin_cannot_ban_self_or_admin": "Нельзя банить себя или админа.",
- "admin_user_not_found_in_bot_db": "User ID {user_id} не найден.",
- "admin_user_banned_success_panel_too": "✅ Юзер {user_id_or_username} забанен (БД и панель).",
- "admin_user_banned_local_panel_fail": "⚠️ Юзер {user_id_or_username} забанен в БД, ошибка панели.",
- "admin_user_banned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} забанен в БД (нет ID панели).",
- "admin_user_unbanned_success_panel_too": "✅ Юзер {user_id_or_username} разбанен (БД и панель).",
- "admin_user_unbanned_local_panel_fail": "⚠️ Юзер {user_id_or_username} разбанен в БД, ошибка панели.",
- "admin_user_unbanned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} разбанен в БД (нет ID панели).",
- "admin_user_already_banned": "Пользователь {user_id_or_username} уже забанен.",
- "admin_user_not_banned": "Пользователь {user_id_or_username} не забанен.",
- "admin_confirm_unban_prompt": "Вы уверены, что хотите разбанить пользователя {user_display} (ID: {user_id})?",
- "admin_confirm_ban_prompt": "Вы уверены, что хотите забанить пользователя {user_display} (ID: {user_id})?",
- "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": "TG ID: {user_id}\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: {panel_user_uuid}\nСтатус: {ban_status}\nРегистрация: {reg_date}\nПодписка до: {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": "Промо: КОД ДНИ МАКС [СРОК_ДН]\nНапример: LETO24 7 100 30 (действ. 30дн)\nНапример: NEWBIE 5 500 (бессроч.)",
- "admin_promo_invalid_format": "Формат: КОД ДНИ ИСП [СРОК_ДН]",
- "admin_promo_invalid_values": "Неверные значения. Дни/исп. > 0. Ошибка: {error}",
- "admin_promo_created_success": "✅ Промо '{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}: {bonus}дн, {current}/{max} исп, До: {valid_until}",
-
+ "admin_panel_title": "Панель администратора",
"admin_stats_button": "📊 Статистика",
- "admin_stats_header": "📊 Статистика бота:",
- "admin_stats_users": "\n👥 Пользователи:\n Всего: {total_users}\n Бан: {banned_users}\n Активные подписки (БД): {active_subs}",
- "admin_stats_recent_payments_header": "\n💳 Недавние платежи (БД бота):",
- "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🔄 Синхр. с панелью:",
+ "admin_broadcast_button": "📢 Рассылка",
+ "admin_create_promo_button": "🎁 Создать промо",
+ "admin_view_promos_button": "👀 Спиок промо",
+ "admin_ban_user_button": "🚫 Забанить",
+ "admin_unban_user_button": "✅ Разбанить",
+ "admin_view_banned_users_button": "📜 Бан-лист",
+ "admin_view_logs_menu_button": "📄 Логи",
+ "admin_sync_panel_button": "🔄 Синхронизация",
+ "admin_unknown_action": "Неизвестное действие администратора.",
+ "admin_action_cancelled_default": "Действие отменено. Возврат в меню.",
+ "admin_action_cancelled_default_alert": "Действие отменено",
+ "back_to_admin_panel_button": "⬅️ В админку",
+
+ "admin_stats_header": "📊 Статистика Бота",
+ "admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}",
+ "admin_stats_recent_payments_header": "Последние платежи:",
+ "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
+ "admin_stats_no_payments_found": "Платежей пока нет.",
+ "admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
"admin_stats_sync_time": "Время",
"admin_stats_sync_status": "Статус",
- "admin_stats_sync_users_processed": "Обработано с панели",
- "admin_stats_sync_subs_synced": "Подписок синхр.",
+ "admin_stats_sync_users_processed": "Обработано юзеров с панели",
+ "admin_stats_sync_subs_synced": "Синхронизировано подписок",
"admin_stats_sync_details_label": "Детали",
- "admin_sync_status_never_run": "Синхронизация не проводилась.",
+ "admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
- "admin_view_logs_menu_button": "📜 Логи",
- "admin_logs_menu_title": "📜 Логи Активности",
- "admin_view_all_logs_button": "Все логи",
- "admin_view_user_logs_prompt_button": "Логи юзера",
- "admin_prompt_for_user_id_or_username_logs": "ID или @username для логов:",
- "admin_all_logs_title": "📜 Активность (Стр. {current_page}/{total_pages})",
- "admin_user_logs_title": "📜 Активность {user_display} (Стр. {current_page}/{total_pages})",
- "admin_no_logs_found": "Логи не найдены.",
- "admin_log_entry_format": "[{timestamp_str}] Юзер: {user_display} (ID: {user_id})\nТип: {event_type}\nИнфо: {content_preview}",
- "admin_log_user_not_found": "Юзер '{input}' не найден для логов.",
- "system_or_unknown_user": "Система/Неизв.",
-
- "admin_sync_panel_button": "🔄 Синхр.",
- "sync_started": "Синхронизация с панелью начата...",
- "sync_completed": "Синхронизация с панелью завершена.\nСтатус: {status}\nДетали: {details}",
- "sync_failed": "Синхронизация с панелью не удалась.\nДетали: {details}",
- "admin_sync_initiated_from_panel": "Синхронизация с панелью запущена!",
- "error_cannot_initiate_sync_no_message": "Ошибка: не могу начать синхронизацию без контекста сообщения.",
+ "admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
+ "admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?",
+ "confirm_broadcast_send_button": "✅ Отправить",
+ "cancel_broadcast_button": "❌ Отмена",
+ "admin_broadcast_sending_started": "Начинаю рассылку...",
+ "admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
+ "admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
+ "admin_broadcast_finished_stats": "🏁 Рассылка завершена!\nОтправлено: {sent_count}\nНе удалось отправить: {failed_count}",
+ "admin_broadcast_cancelled": "Рассылка отменена.",
+ "admin_broadcast_cancelled_alert": "Рассылка отменена!",
+ "admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
- "admin_suspicious_promo_attempt_notification": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (@{user_username}, ID: {user_id})\nВвод: {promo_code_input}",
- "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (ID: {user_id})\nВвод: {promo_code_input}"
+ "admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: {example_format}\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} успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
+ "admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод {code} уже существует.",
+ "admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
+ "admin_active_promos_list_header": "Активные промокоды:",
+ "admin_no_active_promos": "Нет активных промокодов.",
+ "admin_promo_list_item": "{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": "Пользователь {user_id} не найден в базе данных бота.",
+ "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: {user_id}\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: {panel_user_uuid}\nСтатус: {ban_status}\nРегистрация: {reg_date}\nПодписка до: {sub_end_date}",
+ "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} - {user_display} (ID: {user_id})\n {event_type}: {content_preview}",
+ "system_or_unknown_user": "Система/Неизв.",
+ "admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
+ "admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
+ "admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
+
+ "sync_started": "🔄 Начинаю синхронизацию данных с панелью...",
+ "sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}",
+ "sync_completed": "✅ Синхронизация с панелью завершена. Статус: {status}. Детали: {details}",
+ "sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.",
+ "sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
+ "no_errors_placeholder": "нет",
+ "admin_sync_initiated_from_panel": "Синхронизация запущена...",
+ "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
+ "error_displaying_statistics": "Ошибка отображения статистики.",
+ "stub_page_display": "Страница",
+
+ "subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
+
+ "error_unknown": "Произошла неизвестная ошибка."
}
diff --git a/main.py b/main.py
index 7be473a..c2744ed 100644
--- a/main.py
+++ b/main.py
@@ -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)
diff --git a/requirements.txt b/requirements.txt
index 47548ca..9e4d123 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
\ No newline at end of file
+pydantic_settings
+sqlalchemy[asyncio]==2.0.29
+asyncpg==0.29.0
+alembic==1.13.1
\ No newline at end of file