From 87385228ea715adf6a5aa7004c6f9a6a114169ec Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 22:58:52 +0500 Subject: [PATCH] feat(bot): add async DB and FSM state handling --- bot/api/asf.py | 27 ++++++- bot/config.py | 2 + bot/db/__init__.py | 1 + bot/db/base.py | 5 ++ bot/db/init.py | 8 ++ bot/db/models.py | 27 +++++++ bot/db/session.py | 6 ++ bot/filters.py | 6 +- bot/handlers/bots.py | 63 ++++++++++++---- bot/handlers/console.py | 26 ++++--- bot/handlers/inventory.py | 41 ++++++---- bot/handlers/messages.py | 148 +++++++++++++++++++++++++------------ bot/handlers/navigation.py | 76 ++++++++++--------- bot/handlers/redeem.py | 37 ++++++---- bot/handlers/start.py | 14 +++- bot/handlers/twofa.py | 41 +++++----- bot/logging_utils.py | 6 -- bot/services/bots.py | 36 ++++++++- bot/services/inventory.py | 17 +++-- bot/services/redeem.py | 26 +++---- bot/services/twofa.py | 14 +++- bot/services/users.py | 30 ++++++++ bot/state.py | 7 -- bot/states.py | 13 ++++ bot/ui/formatters.py | 41 ++++++---- bot/ui/keyboards.py | 1 + main.py | 8 +- requirements.txt | Bin 914 -> 475 bytes 28 files changed, 514 insertions(+), 213 deletions(-) create mode 100644 bot/db/__init__.py create mode 100644 bot/db/base.py create mode 100644 bot/db/init.py create mode 100644 bot/db/models.py create mode 100644 bot/db/session.py create mode 100644 bot/services/users.py delete mode 100644 bot/state.py create mode 100644 bot/states.py diff --git a/bot/api/asf.py b/bot/api/asf.py index cc42472..76cb983 100644 --- a/bot/api/asf.py +++ b/bot/api/asf.py @@ -14,11 +14,11 @@ def _headers() -> dict[str, str | None]: def asf_get(path: str) -> requests.Response: logger.info("Отправка GET-запроса в ASF: path=%s", path) response = requests.get(f"{ASF_URL}{path}", headers=_headers()) + logger.info( - "Получен ответ ASF на GET-запрос: path=%s status=%s", - path, - response.status_code, + "Получен ответ ASF на GET-запрос: path=%s status=%s", path, response.status_code ) + return response @@ -28,12 +28,14 @@ def asf_post(path: str, payload: dict | None = None) -> requests.Response: path, sorted((payload or {}).keys()), ) + response = requests.post(f"{ASF_URL}{path}", headers=_headers(), json=payload) logger.info( "Получен ответ ASF на POST-запрос: path=%s status=%s", path, response.status_code, ) + return response @@ -41,6 +43,7 @@ def get_asf_status() -> requests.Response: logger.info("Запрос статуса ASF") response = requests.get(f"{ASF_URL}/Api/ASF", headers=_headers()) # type: ignore[arg-type] logger.info("Получен статус ASF: status=%s", response.status_code) + return response @@ -71,6 +74,7 @@ async def get_bot_inventory(bot_name: str, appid: int, contextid: int) -> dict | appid, contextid, ) + async with aiohttp.ClientSession() as session: async with session.get( f"{ASF_URL}/Api/Bot/{bot_name}/Inventory/{appid}/{contextid}", @@ -85,6 +89,7 @@ async def get_bot_inventory(bot_name: str, appid: int, contextid: int) -> dict | response.status, ) return None + data = await response.json() if not data.get("Success"): logger.warning( @@ -94,17 +99,20 @@ async def get_bot_inventory(bot_name: str, appid: int, contextid: int) -> dict | contextid, ) return None + logger.info( "Инвентарь бота получен: bot=%s appid=%s contextid=%s", bot_name, appid, contextid, ) + return data.get("Result") async def get_2fa_token(bot_name: str) -> str: logger.info("Запрос 2FA-кода: bot=%s", bot_name) + async with aiohttp.ClientSession() as session: async with session.get( f"{ASF_URL}/Api/Bot/{bot_name}/TwoFactorAuthentication/Token", @@ -117,24 +125,29 @@ async def get_2fa_token(bot_name: str) -> str: response.status, ) return f"Ошибка HTTP {response.status}" + data = await response.json() if not data.get("Success"): logger.warning( "ASF вернул ошибку при запросе 2FA-кода: bot=%s", bot_name ) return "Ошибка ASF" + try: logger.info("2FA-код успешно получен: bot=%s", bot_name) return data["Result"][bot_name]["Result"] + except Exception: logger.exception( "Не удалось извлечь 2FA-код из ответа ASF: bot=%s", bot_name ) + return "Не удалось получить код" async def get_confirmations(bot_name: str) -> list: logger.info("Запрос подтверждений 2FA: bot=%s", bot_name) + async with aiohttp.ClientSession() as session: async with session.get( f"{ASF_URL}/Api/Bot/{bot_name}/TwoFactorAuthentication/Confirmations", @@ -147,6 +160,7 @@ async def get_confirmations(bot_name: str) -> list: response.status, ) return [] + data = await response.json() if not data.get("Success"): logger.warning( @@ -154,6 +168,7 @@ async def get_confirmations(bot_name: str) -> list: bot_name, ) return [] + try: result = data["Result"][bot_name]["Result"] logger.info( @@ -162,6 +177,7 @@ async def get_confirmations(bot_name: str) -> list: len(result), ) return result + except Exception: logger.exception( "Не удалось извлечь подтверждения 2FA из ответа ASF: bot=%s", @@ -172,6 +188,7 @@ async def get_confirmations(bot_name: str) -> list: async def accept_confirmations(bot_name: str) -> int: logger.info("Отправка запроса на подтверждение всех 2FA-операций: bot=%s", bot_name) + async with aiohttp.ClientSession() as session: async with session.post( f"{ASF_URL}/Api/Bot/{bot_name}/TwoFactorAuthentication/Confirmations/Accept", @@ -182,6 +199,7 @@ async def accept_confirmations(bot_name: str) -> int: bot_name, response.status, ) + return response.status @@ -192,6 +210,7 @@ def save_bot_config(bot_name: str, config: dict) -> requests.Response: async def redeem_key(bot_name: str, key: str) -> tuple[bool, str]: logger.info("Запрос активации ключа: bot=%s", bot_name) + async with aiohttp.ClientSession() as session: async with session.post( f"{ASF_URL}/Api/Command", @@ -205,11 +224,13 @@ async def redeem_key(bot_name: str, key: str) -> tuple[bool, str]: response.status, ) return False, f"HTTP_ERROR_{response.status}" + data = await response.json() if not data.get("Success"): logger.warning( "ASF вернул ошибку при активации ключа: bot=%s", bot_name ) return False, "ASF_ERROR" + logger.info("ASF вернул результат активации ключа: bot=%s", bot_name) return True, str(data.get("Result", "")) diff --git a/bot/config.py b/bot/config.py index eabab43..330c78c 100644 --- a/bot/config.py +++ b/bot/config.py @@ -9,3 +9,5 @@ ADMIN_ID = int(os.getenv("ADMIN_ID")) # type: ignore[arg-type] ASF_URL = os.getenv("ASF_URL") ASF_PASSWORD = os.getenv("ASF_PASSWORD") + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db") diff --git a/bot/db/__init__.py b/bot/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bot/db/__init__.py @@ -0,0 +1 @@ + diff --git a/bot/db/base.py b/bot/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/bot/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/bot/db/init.py b/bot/db/init.py new file mode 100644 index 0000000..e7a18a3 --- /dev/null +++ b/bot/db/init.py @@ -0,0 +1,8 @@ +from bot.db import models # noqa: F401 +from bot.db.base import Base +from bot.db.session import engine + + +async def init_db() -> None: + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) diff --git a/bot/db/models.py b/bot/db/models.py new file mode 100644 index 0000000..a39af60 --- /dev/null +++ b/bot/db/models.py @@ -0,0 +1,27 @@ +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, Boolean, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from bot.db.base import Base + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + username: Mapped[str | None] = mapped_column(String(255), nullable=True) + first_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + onupdate=utc_now, + ) diff --git a/bot/db/session.py b/bot/db/session.py new file mode 100644 index 0000000..31f440b --- /dev/null +++ b/bot/db/session.py @@ -0,0 +1,6 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from bot.config import DATABASE_URL + +engine = create_async_engine(DATABASE_URL) +async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) diff --git a/bot/filters.py b/bot/filters.py index 15bf796..fd26f8f 100644 --- a/bot/filters.py +++ b/bot/filters.py @@ -9,22 +9,26 @@ logger = get_logger(__name__) class AdminFilter(BaseFilter): async def __call__(self, message: Message) -> bool: - allowed = message.from_user.id == ADMIN_ID # type: ignore[union-attr] + allowed = message.from_user.id == ADMIN_ID + if not allowed: logger.warning( "Отклонено сообщение от неавторизованного пользователя: user_id=%s", message.from_user.id if message.from_user else None, ) + return allowed class AdminCallbackFilter(BaseFilter): async def __call__(self, callback: CallbackQuery) -> bool: allowed = callback.from_user.id == ADMIN_ID + if not allowed: logger.warning( "Отклонен callback от неавторизованного пользователя: user_id=%s data=%s", callback.from_user.id, callback.data, ) + return allowed diff --git a/bot/handlers/bots.py b/bot/handlers/bots.py index 63b0a55..6411fa1 100644 --- a/bot/handlers/bots.py +++ b/bot/handlers/bots.py @@ -1,10 +1,13 @@ +import random +import asyncio + from aiogram import Router from aiogram.types import CallbackQuery from bot.api.asf import get_all_bots from bot.filters import AdminCallbackFilter from bot.logging_utils import describe_user, get_logger -from bot.services.bots import is_idle_enabled, update_idle_game +from bot.services.bots import is_bot_reloading, is_idle_enabled, update_idle_game from bot.services.twofa import stop_twofa_task from bot.ui.formatters import format_bot_ui, get_farm_summary from bot.ui.keyboards import bot_details_keyboard, bots_keyboard, games_keyboard @@ -31,7 +34,8 @@ async def bots_handler(callback: CallbackQuery) -> None: "Пользователь %s открыл список ботов", describe_user(callback.from_user), ) - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] + await stop_twofa_task(callback.message.message_id) + await callback.answer() try: response = get_all_bots() @@ -41,16 +45,18 @@ async def bots_handler(callback: CallbackQuery) -> None: describe_user(callback.from_user), response.status_code, ) - await callback.message.edit_text("Ошибка API") # type: ignore[union-attr] + await callback.message.edit_text("Ошибка API") return + data = response.json() if not data.get("Success"): logger.warning( "ASF вернул неуспешный ответ при загрузке списка ботов для %s", describe_user(callback.from_user), ) - await callback.message.edit_text("ASF вернул ошибку") # type: ignore[union-attr] + await callback.message.edit_text("ASF вернул ошибку") return + bots = data.get("Result", {}) logger.info( "Список ботов для %s загружен: %s бот(ов)", @@ -58,33 +64,50 @@ async def bots_handler(callback: CallbackQuery) -> None: len(bots), ) text = f"🤖 Ботов всего: {len(bots)}\n\n{get_farm_summary(bots)}" - await callback.message.edit_text(text, reply_markup=bots_keyboard(bots)) # type: ignore[union-attr] + + await callback.message.edit_text(text, reply_markup=bots_keyboard(bots)) + except Exception as error: logger.exception( "Ошибка при открытии списка ботов для %s", describe_user(callback.from_user), ) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query( lambda c: c.data and c.data.startswith("bot_"), AdminCallbackFilter() ) async def bot_selected(callback: CallbackQuery) -> None: - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] - await callback.answer() + await stop_twofa_task(callback.message.message_id) + if not callback.data: logger.debug( "Колбэк выбора бота пришел без data от %s", describe_user(callback.from_user), ) + await callback.answer() return + bot_name = callback.data.replace("bot_", "") logger.info( "Пользователь %s выбрал бота %s", describe_user(callback.from_user), bot_name, ) + if is_bot_reloading(bot_name): + logger.info( + "Бот %s перезагружается, карточка временно не отображается для %s", + bot_name, + describe_user(callback.from_user), + ) + await callback.answer( + "Аккаунт перезагружается. Попробуйте через несколько секунд.", + show_alert=True, + ) + return + try: data = get_all_bots().json() bots = data.get("Result", {}) @@ -105,20 +128,22 @@ async def bot_selected(callback: CallbackQuery) -> None: bot_name, describe_user(callback.from_user), ) - await callback.message.edit_text( # type: ignore[union-attr] + await callback.answer() + await callback.message.edit_text( format_bot_ui(bot), disable_web_page_preview=True, reply_markup=bot_details_keyboard( bot_name, bool(bot.get("HasMobileAuthenticator")) ), ) + except Exception as error: logger.exception( "Ошибка при открытии карточки бота %s для %s", bot_name, describe_user(callback.from_user), ) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query( @@ -132,6 +157,7 @@ async def games(callback: CallbackQuery) -> None: describe_user(callback.from_user), ) return + bot_name = callback.data.replace("games_", "") idle_enabled = is_idle_enabled(bot_name, 730) logger.info( @@ -140,7 +166,8 @@ async def games(callback: CallbackQuery) -> None: bot_name, idle_enabled, ) - await callback.message.edit_text( # type: ignore[union-attr] + + await callback.message.edit_text( "⌚ Выберите игру для накрутки часов", reply_markup=games_keyboard(bot_name, idle_enabled), ) @@ -157,6 +184,7 @@ async def farm_game(callback: CallbackQuery) -> None: describe_user(callback.from_user), ) return + _, bot_name, app_id_value = callback.data.split("|") logger.info( "Пользователь %s меняет игру для накрутки на боте %s: app_id=%s", @@ -164,6 +192,7 @@ async def farm_game(callback: CallbackQuery) -> None: bot_name, app_id_value, ) + success, text, enabled = await update_idle_game(bot_name, int(app_id_value)) if not success: logger.warning( @@ -174,20 +203,22 @@ async def farm_game(callback: CallbackQuery) -> None: ) await callback.answer(text, show_alert=True) return + logger.info( "Игра для накрутки на боте %s обновлена для %s. CS2=%s", bot_name, describe_user(callback.from_user), enabled, ) + await callback.answer(text, show_alert=True) try: - await callback.message.edit_reply_markup( # type: ignore[union-attr] - reply_markup=games_keyboard(bot_name, enabled) + await callback.message.edit_text( + text=f"⌚ Выберите игру для накрутки часов", + reply_markup=games_keyboard(bot_name, enabled), ) + except Exception: logger.debug( - "Не удалось обновить клавиатуру игр для бота %s", - bot_name, - exc_info=True, + "Не удалось обновить клавиатуру игр для бота %s", bot_name, exc_info=True ) diff --git a/bot/handlers/console.py b/bot/handlers/console.py index b897ebf..fbeb465 100644 --- a/bot/handlers/console.py +++ b/bot/handlers/console.py @@ -1,10 +1,11 @@ from aiogram import Router from aiogram.types import CallbackQuery +from aiogram.fsm.context import FSMContext from bot.filters import AdminCallbackFilter -from bot.logging_utils import describe_user, get_logger -from bot.state import console_users +from bot.states import ConsoleFlow from bot.ui.formatters import get_asf_status_text +from bot.logging_utils import describe_user, get_logger from bot.ui.keyboards import console_keyboard, main_keyboard router = Router() @@ -12,21 +13,26 @@ logger = get_logger(__name__) @router.callback_query(lambda c: c.data == "console", AdminCallbackFilter()) -async def console_enter(callback: CallbackQuery) -> None: +async def console_enter(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Пользователь вошел в режим консоли ASF: %s", user) + + await state.set_state(ConsoleFlow.waiting_command) + await callback.answer() - console_users.add(callback.from_user.id) - await callback.message.edit_text( # type: ignore[union-attr] - "💻 Консоль ASF\n\nОтправь команду (без !)", - reply_markup=console_keyboard(), + await callback.message.edit_text( + "💻 Консоль ASF\n\nОтправь команду (без !)", reply_markup=console_keyboard() ) @router.callback_query(lambda c: c.data == "console_exit", AdminCallbackFilter()) -async def console_exit(callback: CallbackQuery) -> None: +async def console_exit(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Пользователь вышел из режима консоли ASF: %s", user) + + await state.clear() + await callback.answer() - console_users.discard(callback.from_user.id) - await callback.message.edit_text(get_asf_status_text(), reply_markup=main_keyboard()) # type: ignore[union-attr] + await callback.message.edit_text( + get_asf_status_text(), reply_markup=main_keyboard() + ) diff --git a/bot/handlers/inventory.py b/bot/handlers/inventory.py index 09ab9a4..2cf495e 100644 --- a/bot/handlers/inventory.py +++ b/bot/handlers/inventory.py @@ -15,15 +15,18 @@ logger = get_logger(__name__) ) async def inventory_menu(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inventory_", "") # type: ignore[union-attr] + bot_name = callback.data.replace("inventory_", "") logger.info("Открыто меню инвентаря: %s bot=%s", user, bot_name) + await callback.answer() - await callback.message.edit_text("Проверка inventory...") # type: ignore[union-attr] + await callback.message.edit_text("Проверка инвентаря...") + text, keyboard = await build_inventory_menu(bot_name) if "пуст" in text.lower(): logger.info("Показано пустое меню инвентаря: %s bot=%s", user, bot_name) keyboard = back_keyboard(f"bot_{bot_name}") - await callback.message.edit_text(text, reply_markup=keyboard) # type: ignore[union-attr] + + await callback.message.edit_text(text, reply_markup=keyboard) @router.callback_query( @@ -31,16 +34,19 @@ async def inventory_menu(callback: CallbackQuery) -> None: ) async def cs2_inventory(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inv_cs2_", "") # type: ignore[union-attr] + bot_name = callback.data.replace("inv_cs2_", "") logger.info("Открыт CS2-инвентарь: %s bot=%s", user, bot_name) + await callback.answer() - await callback.message.edit_text("Загрузка inventory...") # type: ignore[union-attr] + await callback.message.edit_text("Загрузка инвентаря...") + text, keyboard = await render_inventory_page(bot_name, "cs2", 730, 2, 0) if text is None: logger.warning("Не удалось загрузить CS2-инвентарь: %s bot=%s", user, bot_name) - await callback.message.edit_text("Не удалось загрузить inventory") # type: ignore[union-attr] + await callback.message.edit_text("Не удалось загрузить инвентарь") return - await callback.message.edit_text(text, reply_markup=keyboard) # type: ignore[union-attr] + + await callback.message.edit_text(text, reply_markup=keyboard) @router.callback_query( @@ -48,16 +54,21 @@ async def cs2_inventory(callback: CallbackQuery) -> None: ) async def steam_inventory(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inv_steam_", "") # type: ignore[union-attr] + bot_name = callback.data.replace("inv_steam_", "") logger.info("Открыт Steam-инвентарь: %s bot=%s", user, bot_name) + await callback.answer() - await callback.message.edit_text("Загрузка inventory...") # type: ignore[union-attr] + await callback.message.edit_text("Загрузка инвентаря...") + text, keyboard = await render_inventory_page(bot_name, "steam", 753, 6, 0) if text is None: - logger.warning("Не удалось загрузить Steam-инвентарь: %s bot=%s", user, bot_name) - await callback.message.edit_text("Не удалось загрузить inventory") # type: ignore[union-attr] + logger.warning( + "Не удалось загрузить Steam-инвентарь: %s bot=%s", user, bot_name + ) + await callback.message.edit_text("Не удалось загрузить инвентарь") return - await callback.message.edit_text(text, reply_markup=keyboard) # type: ignore[union-attr] + + await callback.message.edit_text(text, reply_markup=keyboard) @router.callback_query( @@ -65,7 +76,7 @@ async def steam_inventory(callback: CallbackQuery) -> None: ) async def inventory_page(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) - _, inv_type, bot_name, page_value = callback.data.split("|") # type: ignore[union-attr] + _, inv_type, bot_name, page_value = callback.data.split("|") logger.info( "Открыта страница инвентаря: %s bot=%s type=%s page=%s", user, @@ -86,6 +97,6 @@ async def inventory_page(callback: CallbackQuery) -> None: inv_type, page_value, ) - await callback.message.edit_text("Не удалось загрузить inventory") # type: ignore[union-attr] + await callback.message.edit_text("Не удалось загрузить инвентарь") return - await callback.message.edit_text(text, reply_markup=keyboard) # type: ignore[union-attr] + await callback.message.edit_text(text, reply_markup=keyboard) diff --git a/bot/handlers/messages.py b/bot/handlers/messages.py index e0b7b5a..0013480 100644 --- a/bot/handlers/messages.py +++ b/bot/handlers/messages.py @@ -2,50 +2,57 @@ import html from aiogram import Router from aiogram.types import Message +from aiogram.fsm.context import FSMContext -from bot.api.asf import get_all_bots, send_command from bot.filters import AdminFilter +from bot.api.asf import get_all_bots, send_command from bot.logging_utils import describe_user, get_logger from bot.services.redeem import ( extract_keys, redeem_all_bots_keys, redeem_single_bot_keys, ) -from bot.state import ( - bot_redeem_messages, - bot_redeem_users, - console_users, - redeem_messages, - redeem_users, -) + +from bot.states import ConsoleFlow, RedeemFlow + from bot.ui.formatters import format_bot_ui, get_asf_status_text from bot.ui.keyboards import ( + main_keyboard, bot_details_keyboard, delete_message_keyboard, - main_keyboard, ) router = Router() logger = get_logger(__name__) -async def handle_console_message(message: Message) -> bool: - if message.from_user.id not in console_users: # type: ignore[union-attr] - return False - command = message.text.strip() # type: ignore[union-attr] +@router.message(ConsoleFlow.waiting_command, AdminFilter()) +async def handle_console_message(message: Message, state: FSMContext): + if not message.text: + await message.answer("Отправьте текстовую команду.") + return + + command = message.text.strip() + if not command: + await message.answer("Команда не может быть пустой.") + return + logger.info( "Пользователь %s отправил консольную команду: %s", describe_user(message.from_user), command, ) + try: await message.delete() + except Exception: logger.debug( "Не удалось удалить сообщение с консольной командой от %s", describe_user(message.from_user), exc_info=True, ) + try: response = send_command(f"!{command}") if response.status_code != 200: @@ -55,9 +62,12 @@ async def handle_console_message(message: Message) -> bool: command, describe_user(message.from_user), ) + text = f"Ошибка HTTP {response.status_code}" + else: data = response.json() + if not data.get("Success"): logger.warning( "ASF вернул неуспешный ответ на консольную команду '%s' от %s", @@ -65,6 +75,7 @@ async def handle_console_message(message: Message) -> bool: describe_user(message.from_user), ) text = "Ошибка ASF" + else: logger.info( "Консольная команда '%s' от %s выполнена успешно", @@ -73,39 +84,55 @@ async def handle_console_message(message: Message) -> bool: ) result = html.escape(str(data.get("Result", "Нет ответа"))) text = f"Команда: {command}\n\nОтвет:\n{result}" + await message.answer(text, reply_markup=delete_message_keyboard()) + except Exception as error: logger.exception( "Ошибка при выполнении консольной команды '%s' от %s", command, describe_user(message.from_user), ) + await message.answer(f"Ошибка: {error}") - return True -async def handle_single_bot_redeem(message: Message) -> bool: - if message.from_user.id not in bot_redeem_users: # type: ignore[union-attr] - return False - bot_name = bot_redeem_users.pop(message.from_user.id) # type: ignore[union-attr] - menu_message = bot_redeem_messages.pop(message.from_user.id, None) # type: ignore[union-attr] +@router.message(RedeemFlow.waiting_bot_keys, AdminFilter()) +async def handle_single_bot_redeem(message: Message, state: FSMContext): + state_data = await state.get_data() + + bot_name = state_data.get("bot_name") + if not isinstance(bot_name, str) or not bot_name: + await message.answer("Контекст потерян, попробуйте еще раз.") + await state.clear() + + return + + chat_id = message.chat.id + menu_message_id = state_data.get("menu_message_id") + logger.info( "Пользователь %s отправил ключи для бота %s", describe_user(message.from_user), bot_name, ) - if menu_message: + + if menu_message_id: try: response = get_all_bots() bot_data = response.json().get("Result", {}).get(bot_name) + if bot_data: - await menu_message.edit_text( - format_bot_ui(bot_data), - disable_web_page_preview=True, + await message.bot.edit_message_text( + text=format_bot_ui(bot_data), reply_markup=bot_details_keyboard( bot_name, bool(bot_data.get("HasMobileAuthenticator")) ), + disable_web_page_preview=True, + chat_id=chat_id, + message_id=menu_message_id, ) + except Exception: logger.debug( "Не удалось восстановить меню бота %s после ввода ключей от %s", @@ -113,10 +140,12 @@ async def handle_single_bot_redeem(message: Message) -> bool: describe_user(message.from_user), exc_info=True, ) + try: await message.bot.delete_message( chat_id=message.chat.id, message_id=message.message_id ) + except Exception: logger.debug( "Не удалось удалить сообщение с ключами для бота %s от %s", @@ -124,6 +153,7 @@ async def handle_single_bot_redeem(message: Message) -> bool: describe_user(message.from_user), exc_info=True, ) + keys = extract_keys(message.text or "") if not keys: logger.info( @@ -132,7 +162,10 @@ async def handle_single_bot_redeem(message: Message) -> bool: bot_name, ) await message.answer("Ключи не найдены") - return True + await state.clear() + + return + logger.info( "Для бота %s получено %s ключ(ей) от %s", bot_name, @@ -142,40 +175,66 @@ async def handle_single_bot_redeem(message: Message) -> bool: checking = await message.answer( f"Аккаунт: {bot_name}\nНайдено ключей: {len(keys)}\nНачинаю активацию..." ) - await checking.edit_text(await redeem_single_bot_keys(bot_name, keys)) - return True + + try: + await checking.edit_text(await redeem_single_bot_keys(bot_name, keys)) + + except Exception as error: + logger.exception( + "Не удалось активировать ключи для бота %s от %s", + bot_name, + describe_user(message.from_user), + ) + try: + await checking.edit_text(f"Ошибка: {error}") + + except Exception: + await message.answer(f"Ошибка: {error}") + + finally: + await state.clear() -async def handle_all_bots_redeem(message: Message) -> bool: - if message.from_user.id not in redeem_users: # type: ignore[union-attr] - return False - redeem_users.discard(message.from_user.id) # type: ignore[union-attr] - menu_message = redeem_messages.pop(message.from_user.id, None) # type: ignore[union-attr] +@router.message(RedeemFlow.waiting_all_keys, AdminFilter()) +async def handle_all_bots_redeem(message: Message, state: FSMContext): + state_data = await state.get_data() + + chat_id = message.chat.id + menu_message_id = state_data.get("menu_message_id") + logger.info( "Пользователь %s отправил ключи для активации на всех ботах", describe_user(message.from_user), ) - if menu_message: + + if menu_message_id: try: - await menu_message.edit_text( - get_asf_status_text(), reply_markup=main_keyboard() + await message.bot.edit_message_text( + text=get_asf_status_text(), + reply_markup=main_keyboard(), + chat_id=chat_id, + message_id=menu_message_id, ) + except Exception: logger.debug( "Не удалось восстановить главное меню после массовой активации для %s", describe_user(message.from_user), exc_info=True, ) + try: await message.bot.delete_message( chat_id=message.chat.id, message_id=message.message_id ) + except Exception: logger.debug( "Не удалось удалить сообщение с массовой активацией от %s", describe_user(message.from_user), exc_info=True, ) + keys = extract_keys(message.text or "") if not keys: logger.info( @@ -183,7 +242,10 @@ async def handle_all_bots_redeem(message: Message) -> bool: describe_user(message.from_user), ) await message.answer("Ключи не найдены") - return True + await state.clear() + + return + logger.info( "Для массовой активации получено %s ключ(ей) от %s", len(keys), @@ -192,25 +254,15 @@ async def handle_all_bots_redeem(message: Message) -> bool: checking = await message.answer( f"Найдено ключей: {len(keys)}\nНачинаю активацию..." ) + try: await checking.edit_text(await redeem_all_bots_keys(keys)) + except Exception as error: logger.exception( "Ошибка при массовой активации ключей от %s", describe_user(message.from_user), ) await checking.edit_text(f"Ошибка: {error}") - return True - -@router.message(AdminFilter()) -async def text_router(message: Message) -> None: - logger.debug( - "Получено текстовое сообщение от %s для маршрутизации", - describe_user(message.from_user), - ) - if await handle_console_message(message): - return - if await handle_single_bot_redeem(message): - return - await handle_all_bots_redeem(message) + await state.clear() diff --git a/bot/handlers/navigation.py b/bot/handlers/navigation.py index bc5fb63..d060be5 100644 --- a/bot/handlers/navigation.py +++ b/bot/handlers/navigation.py @@ -2,18 +2,15 @@ import asyncio from aiogram import Router from aiogram.types import CallbackQuery +from aiogram.fsm.context import FSMContext -from bot.api.asf import get_plugins, restart_asf as restart_asf_request from bot.filters import AdminCallbackFilter from bot.logging_utils import describe_user, get_logger + +from bot.api.asf import get_plugins, restart_asf as restart_asf_request from bot.services.twofa import stop_twofa_task -from bot.state import ( - bot_redeem_messages, - bot_redeem_users, - console_users, - redeem_messages, - redeem_users, -) + + from bot.ui.formatters import get_asf_status_text, get_asf_status from bot.ui.keyboards import back_keyboard, confirm_action_keyboard, main_keyboard @@ -21,28 +18,24 @@ router = Router() logger = get_logger(__name__) -def clear_user_state(user_id: int) -> None: - redeem_users.discard(user_id) - redeem_messages.pop(user_id, None) - console_users.discard(user_id) - bot_redeem_users.pop(user_id, None) - bot_redeem_messages.pop(user_id, None) - logger.info("Состояние пользователя очищено: user_id=%s", user_id) - - @router.callback_query(lambda c: c.data == "back", AdminCallbackFilter()) -async def back_button(callback: CallbackQuery) -> None: +async def back_button(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Нажата кнопка возврата: %s", user) - clear_user_state(callback.from_user.id) - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] + + await state.clear() + await stop_twofa_task(callback.message.message_id) + await callback.answer() try: - await callback.message.edit_text(get_asf_status_text(), reply_markup=main_keyboard()) # type: ignore[union-attr] + await callback.message.edit_text( + get_asf_status_text(), reply_markup=main_keyboard() + ) logger.info("Пользователь возвращен в главное меню: %s", user) + except Exception as error: logger.exception("Не удалось вернуть пользователя в главное меню: %s", user) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query(lambda c: c.data == "refresh", AdminCallbackFilter()) @@ -50,22 +43,28 @@ async def refresh_handler(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Запрошено обновление главного экрана: %s", user) await callback.answer() + try: - await callback.message.edit_text(get_asf_status_text(), reply_markup=main_keyboard()) # type: ignore[union-attr] + await callback.message.edit_text( + get_asf_status_text(), reply_markup=main_keyboard() + ) logger.info("Главный экран обновлен: %s", user) + except Exception as error: logger.exception("Не удалось обновить главный экран: %s", user) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query(lambda c: c.data == "delete_msg", AdminCallbackFilter()) async def delete_message(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Запрошено удаление сообщения: %s", user) + await callback.answer() try: - await callback.message.delete() # type: ignore[union-attr] + await callback.message.delete() logger.info("Сообщение удалено: %s", user) + except Exception: logger.exception("Не удалось удалить сообщение: %s", user) @@ -74,8 +73,9 @@ async def delete_message(callback: CallbackQuery) -> None: async def restart_asf_confirm(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Открыто подтверждение перезапуска ASF: %s", user) + await callback.answer() - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( "♻ Точно перезапустить ASF?\n\nВо время перезапуска IPC будет недоступен несколько секунд.", reply_markup=confirm_action_keyboard("restart_asf", "back"), ) @@ -85,9 +85,10 @@ async def restart_asf_confirm(callback: CallbackQuery) -> None: async def restart_asf(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Подтвержден перезапуск ASF: %s", user) + await callback.answer() try: - await callback.message.edit_text("♻ ASF перезапускается...") # type: ignore[union-attr] + await callback.message.edit_text("♻ ASF перезапускается...") response = restart_asf_request() if response.status_code != 200: @@ -96,7 +97,7 @@ async def restart_asf(callback: CallbackQuery) -> None: user, response.status_code, ) - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( f"Ошибка HTTP {response.status_code}", reply_markup=main_keyboard(), ) @@ -113,7 +114,7 @@ async def restart_asf(callback: CallbackQuery) -> None: status = get_asf_status() await asyncio.sleep(1) - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( f"ASF успешно перезапущен\n\n{get_asf_status_text()}", reply_markup=main_keyboard(), ) @@ -123,7 +124,7 @@ async def restart_asf(callback: CallbackQuery) -> None: except Exception as error: logger.exception("Не удалось перезапустить ASF: %s", user) - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( f"Ошибка: {error}", reply_markup=main_keyboard(), ) @@ -133,7 +134,8 @@ async def restart_asf(callback: CallbackQuery) -> None: async def plugins_handler(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Открыт список плагинов ASF: %s", user) - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] + await stop_twofa_task(callback.message.message_id) + await callback.answer() try: response = get_plugins() @@ -143,13 +145,15 @@ async def plugins_handler(callback: CallbackQuery) -> None: user, response.status_code, ) - await callback.message.edit_text("Ошибка API") # type: ignore[union-attr] + await callback.message.edit_text("Ошибка API") return + data = response.json() if not data.get("Success"): logger.warning("ASF вернул ошибку при получении плагинов: %s", user) - await callback.message.edit_text("ASF вернул ошибку") # type: ignore[union-attr] + await callback.message.edit_text("ASF вернул ошибку") return + plugins = data.get("Result", []) if not plugins: logger.info("Плагины ASF не установлены: %s", user) @@ -161,7 +165,9 @@ async def plugins_handler(callback: CallbackQuery) -> None: text += ( f"{plugin.get('Name', '???')} ({plugin.get('Version', '???')})\n" ) - await callback.message.edit_text(text.strip(), reply_markup=back_keyboard()) # type: ignore[union-attr] + + await callback.message.edit_text(text.strip(), reply_markup=back_keyboard()) + except Exception as error: logger.exception("Не удалось отобразить список плагинов: %s", user) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") diff --git a/bot/handlers/redeem.py b/bot/handlers/redeem.py index 4592c81..0e0b3ba 100644 --- a/bot/handlers/redeem.py +++ b/bot/handlers/redeem.py @@ -1,14 +1,12 @@ from aiogram import Router from aiogram.types import CallbackQuery +from aiogram.fsm.context import FSMContext from bot.filters import AdminCallbackFilter from bot.logging_utils import describe_user, get_logger -from bot.state import ( - bot_redeem_messages, - bot_redeem_users, - redeem_messages, - redeem_users, -) + + +from bot.states import RedeemFlow from bot.ui.keyboards import back_keyboard router = Router() @@ -16,33 +14,40 @@ logger = get_logger(__name__) @router.callback_query(lambda c: c.data == "redeem_keys", AdminCallbackFilter()) -async def redeem_keys_menu(callback: CallbackQuery) -> None: +async def redeem_keys_menu(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Открыт режим активации ключей по всем ботам: %s", user) + await callback.answer() - redeem_users.add(callback.from_user.id) - redeem_messages[callback.from_user.id] = callback.message - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( "Отправьте ключ Steam для активации\n\n" "Можно отправить сразу несколько ключей.\n\n" "Пример:\n" "AAAAA-BBBBB-CCCCC\nDDDDD-EEEEE-FFFFF", reply_markup=back_keyboard(), ) + await state.set_state(RedeemFlow.waiting_all_keys) + await state.update_data(menu_message_id=callback.message.message_id) @router.callback_query( lambda c: c.data and c.data.startswith("redeem_bot_"), AdminCallbackFilter() ) -async def redeem_bot_menu(callback: CallbackQuery) -> None: +async def redeem_bot_menu(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("redeem_bot_", "") # type: ignore[union-attr] - logger.info("Открыт режим активации ключей для одного бота: %s bot=%s", user, bot_name) + + bot_name = callback.data.replace("redeem_bot_", "") + logger.info( + "Открыт режим активации ключей для одного бота: %s bot=%s", user, bot_name + ) + await callback.answer() - bot_redeem_users[callback.from_user.id] = bot_name - bot_redeem_messages[callback.from_user.id] = callback.message - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( f"Отправьте ключ Steam для активации на аккаунте:\n{bot_name}\n\n" "Можно отправить сразу несколько ключей.", reply_markup=back_keyboard(f"bot_{bot_name}"), ) + await state.set_state(RedeemFlow.waiting_bot_keys) + await state.update_data( + bot_name=bot_name, menu_message_id=callback.message.message_id + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index e3bb509..f366af2 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -1,11 +1,14 @@ from aiogram import Router -from aiogram.filters import Command from aiogram.types import Message +from aiogram.filters import Command from bot.filters import AdminFilter -from bot.logging_utils import describe_user, get_logger -from bot.ui.formatters import get_asf_status_text +from bot.services.users import upsert_user_from_telegram + from bot.ui.keyboards import main_keyboard +from bot.ui.formatters import get_asf_status_text + +from bot.logging_utils import describe_user, get_logger router = Router() logger = get_logger(__name__) @@ -14,15 +17,20 @@ logger = get_logger(__name__) @router.message(Command("start"), AdminFilter()) async def start_handler(message: Message) -> None: user = describe_user(message.from_user.id if message.from_user else None) + await upsert_user_from_telegram(message.from_user) + logger.info("Получена команда /start: %s", user) try: await message.delete() logger.info("Сообщение /start удалено: %s", user) + except Exception: logger.exception("Не удалось удалить сообщение /start: %s", user) + try: await message.answer(get_asf_status_text(), reply_markup=main_keyboard()) logger.info("Главное меню отправлено: %s", user) + except Exception as error: logger.exception("Не удалось отправить главное меню: %s", user) await message.answer(f"Ошибка: {error}") diff --git a/bot/handlers/twofa.py b/bot/handlers/twofa.py index ce2f1b5..483d40d 100644 --- a/bot/handlers/twofa.py +++ b/bot/handlers/twofa.py @@ -3,11 +3,11 @@ import asyncio from aiogram import Router from aiogram.types import CallbackQuery -from bot.api.asf import accept_confirmations, get_confirmations +from bot.states import twofa_tasks from bot.filters import AdminCallbackFilter from bot.logging_utils import describe_user, get_logger from bot.services.twofa import auto_update_2fa, stop_twofa_task -from bot.state import twofa_tasks +from bot.api.asf import accept_confirmations, get_confirmations from bot.ui.keyboards import back_keyboard, confirmations_keyboard router = Router() @@ -29,14 +29,14 @@ async def twofa_handler(callback: CallbackQuery) -> None: ) return bot_name = callback.data.replace("2fa_", "") - message_id = callback.message.message_id # type: ignore[union-attr] + message_id = callback.message.message_id logger.info( "Пользователь %s открыл 2FA для бота %s", describe_user(callback.from_user), bot_name, ) await stop_twofa_task(message_id) - await callback.message.edit_text( # type: ignore[union-attr] + await callback.message.edit_text( f"🔐 {bot_name}\n\nЗагрузка 2FA...", reply_markup=back_keyboard(f"bot_{bot_name}"), ) @@ -58,7 +58,7 @@ async def twofa_handler(callback: CallbackQuery) -> None: ) async def confirm_trades(callback: CallbackQuery) -> None: await callback.answer() - bot_name = callback.data.replace("confirm_", "") # type: ignore[union-attr] + bot_name = callback.data.replace("confirm_", "") logger.info( "Пользователь %s подтвердил сделки для бота %s", describe_user(callback.from_user), @@ -82,14 +82,14 @@ async def confirm_trades(callback: CallbackQuery) -> None: "Ошибка при подтверждении сделок для бота %s", bot_name, ) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query( lambda c: c.data and c.data.startswith("confirm_list_"), AdminCallbackFilter() ) async def confirm_list(callback: CallbackQuery) -> None: - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] + await stop_twofa_task(callback.message.message_id) await callback.answer() if not callback.data: logger.debug( @@ -117,20 +117,23 @@ async def confirm_list(callback: CallbackQuery) -> None: text = f"🔐 {bot_name}\n\nПодтверждения:\n\n" for conf in confirmations: text += f"- {conf['type_name']}\nID: {conf['id']}\n\n" - await callback.message.edit_text(text, reply_markup=confirmations_keyboard(bot_name)) # type: ignore[union-attr] + await callback.message.edit_text( + text, reply_markup=confirmations_keyboard(bot_name) + ) except Exception as error: logger.exception( "Ошибка при загрузке списка подтверждений для бота %s", bot_name, ) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.message.edit_text(f"Ошибка: {error}") @router.callback_query( lambda c: c.data and c.data.startswith("confirm_all_"), AdminCallbackFilter() ) async def confirm_all(callback: CallbackQuery) -> None: - await stop_twofa_task(callback.message.message_id) # type: ignore[union-attr] + await stop_twofa_task(callback.message.message_id) + await callback.answer() if not callback.data: logger.debug( @@ -138,12 +141,14 @@ async def confirm_all(callback: CallbackQuery) -> None: describe_user(callback.from_user), ) return + bot_name = callback.data.replace("confirm_all_", "") logger.info( "Пользователь %s запустил подтверждение всех сделок для бота %s", describe_user(callback.from_user), bot_name, ) + try: status = await accept_confirmations(bot_name) if status != 200: @@ -154,6 +159,7 @@ async def confirm_all(callback: CallbackQuery) -> None: ) await callback.answer("Ошибка HTTP", show_alert=True) return + confirmations = await get_confirmations(bot_name) if not confirmations: logger.info("После подтверждения у бота %s не осталось сделок", bot_name) @@ -167,11 +173,12 @@ async def confirm_all(callback: CallbackQuery) -> None: text = f"🔐 {bot_name}\n\nПодтверждения:\n\n" for conf in confirmations: text += f"- {conf['type_name']}\nID: {conf['id']}\n\n" - await callback.message.edit_text(text, reply_markup=confirmations_keyboard(bot_name)) # type: ignore[union-attr] - await callback.answer("Все подтверждено", show_alert=True) - except Exception as error: - logger.exception( - "Ошибка при подтверждении всех сделок для бота %s", - bot_name, + + await callback.message.edit_text( + text, reply_markup=confirmations_keyboard(bot_name) ) - await callback.message.edit_text(f"Ошибка: {error}") # type: ignore[union-attr] + await callback.answer("Все подтверждено", show_alert=True) + + except Exception as error: + logger.exception("Ошибка при подтверждении всех сделок для бота %s", bot_name) + await callback.message.edit_text(f"Ошибка: {error}") diff --git a/bot/logging_utils.py b/bot/logging_utils.py index 0edc394..dd0b272 100644 --- a/bot/logging_utils.py +++ b/bot/logging_utils.py @@ -12,9 +12,3 @@ def describe_user(user: Any) -> str: user_id = getattr(user, "id", user) return f"пользователь={user_id}" - - -def mask_key(key: str) -> str: - if len(key) < 9: - return "***" - return f"{key[:5]}-*****-*****" diff --git a/bot/services/bots.py b/bot/services/bots.py index a174522..38b18ce 100644 --- a/bot/services/bots.py +++ b/bot/services/bots.py @@ -4,6 +4,27 @@ from bot.api.asf import get_all_bots, get_bot, save_bot_config, send_command from bot.logging_utils import get_logger logger = get_logger(__name__) +RELOAD_POLL_INTERVAL_SECONDS = 1 +RELOAD_TIMEOUT_SECONDS = 20 +_reloading_bots: set[str] = set() + + +def is_bot_reloading(bot_name: str) -> bool: + return bot_name in _reloading_bots + + +def _is_bot_loaded_and_online(bot_name: str) -> bool: + response = get_all_bots() + if response.status_code != 200: + return False + + data = response.json() + bot = data.get("Result", {}).get(bot_name, {}) + return bool( + bot.get("BotName") + and bot.get("SteamID") + and bot.get("IsConnectedAndLoggedOn") + ) def is_bot_playing(bot_name: str) -> bool: @@ -29,6 +50,7 @@ def is_bot_playing(bot_name: str) -> bool: def is_idle_enabled(bot_name: str, app_id: int) -> bool: logger.info("Проверка статуса idle-игры: bot=%s app_id=%s", bot_name, app_id) response = get_bot(bot_name) + if response.status_code != 200: logger.warning( "Не удалось получить конфиг бота для проверки idle: bot=%s status=%s", @@ -63,17 +85,27 @@ def toggle_idle_game(config: dict, app_id: int) -> tuple[dict, bool]: async def reload_bot(bot_name: str) -> None: + _reloading_bots.add(bot_name) try: logger.info("Отправка команды перезагрузки бота: bot=%s", bot_name) send_command(f"!reload {bot_name}") + for _ in range(RELOAD_TIMEOUT_SECONDS): + await asyncio.sleep(RELOAD_POLL_INTERVAL_SECONDS) + if _is_bot_loaded_and_online(bot_name): + logger.info("Перезагрузка бота завершена: bot=%s", bot_name) + return + logger.warning("Истекло время ожидания перезагрузки бота: bot=%s", bot_name) except Exception: logger.exception( "Не удалось отправить команду перезагрузки бота: bot=%s", bot_name ) + finally: + _reloading_bots.discard(bot_name) async def update_idle_game(bot_name: str, app_id: int) -> tuple[bool, str, bool]: logger.info("Изменение настройки idle-игры: bot=%s app_id=%s", bot_name, app_id) + response = get_bot(bot_name) if response.status_code != 200: logger.warning( @@ -81,6 +113,7 @@ async def update_idle_game(bot_name: str, app_id: int) -> tuple[bool, str, bool] bot_name, response.status_code, ) + return False, "Ошибка API", False data = response.json() @@ -97,8 +130,9 @@ async def update_idle_game(bot_name: str, app_id: int) -> tuple[bool, str, bool] ) return False, "Ошибка сохранения", enabled + _reloading_bots.add(bot_name) asyncio.create_task(reload_bot(bot_name)) - action = "Idle включен" if enabled else "Idle выключен" + action = "Накрутка часов включена" if enabled else "Накрутка часов выключена" logger.info( "Настройка idle-игры изменена: bot=%s app_id=%s enabled=%s", bot_name, diff --git a/bot/services/inventory.py b/bot/services/inventory.py index bb81057..363af46 100644 --- a/bot/services/inventory.py +++ b/bot/services/inventory.py @@ -14,11 +14,11 @@ async def build_inventory_menu(bot_name: str) -> tuple[str, object]: cs2_assets = [] steam_assets = [] try: - cs2_assets = cs2_inventory.get(bot_name, {}).get("Assets", []) # type: ignore[union-attr] + cs2_assets = cs2_inventory.get(bot_name, {}).get("Assets", []) except Exception: logger.exception("Ошибка чтения CS2-инвентаря: bot=%s", bot_name) try: - steam_assets = steam_inventory.get(bot_name, {}).get("Assets", []) # type: ignore[union-attr] + steam_assets = steam_inventory.get(bot_name, {}).get("Assets", []) except Exception: logger.exception("Ошибка чтения Steam-инвентаря: bot=%s", bot_name) if not cs2_assets and not steam_assets: @@ -67,7 +67,9 @@ async def render_inventory_page( assets = bot_inventory.get("Assets", []) descriptions = bot_inventory.get("Descriptions", []) if not assets: - logger.info("Инвентарь выбранного типа пуст: bot=%s type=%s", bot_name, inventory_type) + logger.info( + "Инвентарь выбранного типа пуст: bot=%s type=%s", bot_name, inventory_type + ) return f"Инвентарь {bot_name} пуст", inventory_menu_keyboard(bot_name, 0, 0) desc_map = {} for desc in descriptions: @@ -87,7 +89,7 @@ async def render_inventory_page( for asset in page_assets: key = (str(asset.get("classid")), str(asset.get("instanceid"))) desc = desc_map.get(key, {}) - name = desc.get("market_name", "Unknown") + name = desc.get("market_name", "Неизвестно") amount = asset.get("amount", 1) tradable = "🔄" if desc.get("tradable") else "❌" marketable = "💰" if desc.get("marketable") else "🔒" @@ -105,12 +107,14 @@ async def render_inventory_page( elif icon == "💎": gems_count += amount lines.append(f"{icon} {tradable}{marketable} {name} x{amount}") + inv_name = "CS2" if appid == 730 else "Steam" stats = ( f"📦 Предметов: {len(assets)}\n" f"💰 Можно продать: {marketable_count}\n" f"🔄 Можно трейдить: {tradable_count}" ) + if appid == 753: stats += ( f"\n🎏 Карточек: {cards_count}" @@ -118,11 +122,13 @@ async def render_inventory_page( f"\n🖼 Фонов: {backgrounds_count}" f"\n💎 Самоцветов: {gems_count}" ) + text = ( - f"📦 {inv_name} Inventory {bot_name}\n" + f"📦 Инвентарь {inv_name} {bot_name}\n" f"📄 Страница {page + 1}/{total_pages}\n\n" f"{stats}\n\n" + "\n".join(lines) ) + logger.info( "Страница инвентаря сформирована: bot=%s type=%s page=%s total_pages=%s items_on_page=%s", bot_name, @@ -131,6 +137,7 @@ async def render_inventory_page( total_pages, len(page_assets), ) + return text[:4000], inventory_page_keyboard( inventory_type, bot_name, page, total_pages ) diff --git a/bot/services/redeem.py b/bot/services/redeem.py index 08cac8b..d703f5b 100644 --- a/bot/services/redeem.py +++ b/bot/services/redeem.py @@ -1,8 +1,8 @@ import asyncio import re +from bot.logging_utils import get_logger from bot.api.asf import get_all_bots, redeem_key -from bot.logging_utils import get_logger, mask_key KEY_PATTERN = r"[A-Z0-9]{5}(?:-[A-Z0-9]{5}){2}" logger = get_logger(__name__) @@ -52,7 +52,7 @@ async def redeem_single_bot_keys(bot_name: str, keys: list[str]) -> str: logger.info( "Попытка активации ключа для одного бота: bot=%s key=%s", bot_name, - mask_key(key), + key, ) success, result = await redeem_key(bot_name, key) await asyncio.sleep(2) @@ -62,16 +62,16 @@ async def redeem_single_bot_keys(bot_name: str, keys: list[str]) -> str: logger.warning( "Активация ключа завершилась ошибкой ASF/HTTP: bot=%s key=%s", bot_name, - mask_key(key), + key, ) - results.append(f"❌ {key}: ASF ERROR") + results.append(f"❌ {key}: ошибка ASF") continue status = parse_redeem_result(result) logger.info( "Получен результат активации ключа: bot=%s key=%s status=%s", bot_name, - mask_key(key), + key, status, ) @@ -80,7 +80,7 @@ async def redeem_single_bot_keys(bot_name: str, keys: list[str]) -> str: results.append(f"✅ {key}: активировано") elif status == "rate_limited": failed_count += 1 - results.append(f"⏳ {key}: rate limit") + results.append(f"⏳ {key}: лимит запросов") elif status == "already_owned": failed_count += 1 results.append(f"⚠️ {key}: игра уже есть") @@ -124,13 +124,13 @@ async def redeem_all_bots_keys(keys: list[str]) -> str: for key in keys: activated = False key_report = [] - logger.info("Начата обработка ключа по всем ботам: key=%s", mask_key(key)) + logger.info("Начата обработка ключа по всем ботам: key=%s", key) for bot_name in bots: logger.info( "Попытка активации ключа на боте: bot=%s key=%s", bot_name, - mask_key(key), + key, ) success, result = await redeem_key(bot_name, key) await asyncio.sleep(2) @@ -139,16 +139,16 @@ async def redeem_all_bots_keys(keys: list[str]) -> str: logger.warning( "Активация ключа завершилась ошибкой ASF/HTTP: bot=%s key=%s", bot_name, - mask_key(key), + key, ) - key_report.append(f"❌ {bot_name}: ASF ERROR") + key_report.append(f"❌ {bot_name}: ошибка ASF") continue status = parse_redeem_result(result, bot_name) logger.info( "Получен результат активации ключа: bot=%s key=%s status=%s", bot_name, - mask_key(key), + key, status, ) @@ -158,7 +158,7 @@ async def redeem_all_bots_keys(keys: list[str]) -> str: activated = True break if status == "rate_limited": - key_report.append(f"⏳ {bot_name}: rate limit") + key_report.append(f"⏳ {bot_name}: лимит запросов") continue if status == "already_owned": key_report.append(f"⚠️ {bot_name}: игра уже есть") @@ -178,7 +178,7 @@ async def redeem_all_bots_keys(keys: list[str]) -> str: failed_count += 1 logger.warning( "Ключ не удалось активировать ни на одном боте: key=%s", - mask_key(key), + key, ) results.append(f"\n🔑 {key}\n" + "\n".join(key_report)) diff --git a/bot/services/twofa.py b/bot/services/twofa.py index f35efe2..c7d08cb 100644 --- a/bot/services/twofa.py +++ b/bot/services/twofa.py @@ -1,22 +1,25 @@ import asyncio -from bot.api.asf import get_2fa_token, get_confirmations +from bot.states import twofa_tasks from bot.logging_utils import get_logger -from bot.state import twofa_tasks from bot.ui.keyboards import twofa_keyboard +from bot.api.asf import get_2fa_token, get_confirmations logger = get_logger(__name__) async def stop_twofa_task(message_id: int) -> None: task = twofa_tasks.pop(message_id, None) + if task: logger.info( "Остановка фоновой задачи обновления 2FA: message_id=%s", message_id ) task.cancel() + try: await task + except Exception: logger.exception( "Ошибка при завершении фоновой задачи обновления 2FA: message_id=%s", @@ -32,6 +35,7 @@ async def auto_update_2fa(message, bot_name: str) -> None: bot_name, message_id, ) + try: while True: code = await get_2fa_token(bot_name) @@ -43,6 +47,7 @@ async def auto_update_2fa(message, bot_name: str) -> None: text, reply_markup=twofa_keyboard(bot_name, bool(confirmations)), ) + last_code = code logger.info( "Сообщение 2FA обновлено: bot=%s message_id=%s confirmations=%s", @@ -50,6 +55,7 @@ async def auto_update_2fa(message, bot_name: str) -> None: message_id, len(confirmations), ) + except Exception: logger.exception( "Не удалось обновить сообщение 2FA: bot=%s message_id=%s", @@ -57,13 +63,16 @@ async def auto_update_2fa(message, bot_name: str) -> None: message_id, ) break + await asyncio.sleep(15) + except asyncio.CancelledError: logger.info( "Фоновое обновление 2FA отменено: bot=%s message_id=%s", bot_name, message_id, ) + except Exception as error: logger.exception( "Фоновое обновление 2FA завершилось ошибкой: bot=%s message_id=%s error=%s", @@ -71,6 +80,7 @@ async def auto_update_2fa(message, bot_name: str) -> None: message_id, error, ) + finally: twofa_tasks.pop(message_id, None) logger.info( diff --git a/bot/services/users.py b/bot/services/users.py new file mode 100644 index 0000000..65db96f --- /dev/null +++ b/bot/services/users.py @@ -0,0 +1,30 @@ +from aiogram.types import User as TelegramUser +from sqlalchemy import select + +from bot.config import ADMIN_ID +from bot.db.models import User +from bot.db.session import async_session + + +async def upsert_user_from_telegram(telegram_user: TelegramUser | None) -> User | None: + if telegram_user is None: + return None + + async with async_session() as session: + result = await session.execute( + select(User).where(User.telegram_id == telegram_user.id) + ) + user = result.scalar_one_or_none() + + if user is None: + user = User(telegram_id=telegram_user.id) + session.add(user) + + user.username = telegram_user.username + user.first_name = telegram_user.first_name + user.last_name = telegram_user.last_name + user.is_admin = telegram_user.id == ADMIN_ID + + await session.commit() + await session.refresh(user) + return user diff --git a/bot/state.py b/bot/state.py deleted file mode 100644 index 1560a08..0000000 --- a/bot/state.py +++ /dev/null @@ -1,7 +0,0 @@ -console_users = set() -redeem_users = set() -twofa_tasks = {} -inventory_cache = {} -redeem_messages = {} -bot_redeem_users = {} -bot_redeem_messages = {} diff --git a/bot/states.py b/bot/states.py new file mode 100644 index 0000000..9790914 --- /dev/null +++ b/bot/states.py @@ -0,0 +1,13 @@ +from aiogram.fsm.state import State, StatesGroup + + +class ConsoleFlow(StatesGroup): + waiting_command = State() + + +class RedeemFlow(StatesGroup): + waiting_all_keys = State() + waiting_bot_keys = State() + + +twofa_tasks = {} diff --git a/bot/ui/formatters.py b/bot/ui/formatters.py index 65705e0..a8c4f37 100644 --- a/bot/ui/formatters.py +++ b/bot/ui/formatters.py @@ -24,46 +24,58 @@ def get_currency_name(currency_id: int) -> str: def get_inventory_icon(desc: dict) -> str: tags = desc.get("tags", []) + for tag in tags: category = tag.get("category", "") name = tag.get("localized_tag_name", "") + if category == "item_class": if "Trading Card" in name: return "🎏" - if "Emoticon" in name: + elif "Emoticon" in name: return "😀" - if "Profile Background" in name: + elif "Profile Background" in name: return "🖼" - if "Booster Pack" in name: + elif "Booster Pack" in name: return "📦" - if "Steam Gems" in name: + elif "Steam Gems" in name: return "💎" + if "Gift" in name: return "🎁" item_type = desc.get("type", "").lower() if "knife" in item_type: return "🔪" - if "pistol" in item_type: + elif "pistol" in item_type: return "🔫" - if "rifle" in item_type: + elif "rifle" in item_type: return "🎯" - if "graffiti" in item_type: + elif "graffiti" in item_type: return "🎨" - if "music kit" in item_type: + elif "music kit" in item_type: return "🎵" + return "📦" def get_uptime(start_time_str: str) -> str: start_time_str = start_time_str[:26] + "Z" start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) delta = now - start_time days = delta.days + hours, remainder = divmod(delta.seconds, 3600) minutes, seconds = divmod(remainder, 60) - return f"{days}д {hours}ч {minutes}м {seconds}с" + + days_measure = f"{days}д" if days > 0 else "" + hours_measure = f" {hours}ч" if hours > 0 else "" + minutes_measure = f" {minutes}м" if minutes > 0 else "" + seconds_measure = f" {seconds}с" if seconds > 0 else "" + + return f"{days_measure}{hours_measure}{minutes_measure}{seconds_measure}" def format_asf(data: dict) -> str: @@ -72,9 +84,9 @@ def format_asf(data: dict) -> str: current_version = result.get("Version") latest_version = result.get("LatestVersion", current_version) - lines = [f"Версия ASF: `{current_version}`"] + lines = [f"Версия ASF: {current_version}"] if current_version != latest_version: - lines.append(f"Доступно обновление: `{latest_version}`") + lines.append(f"Доступно обновление: {latest_version}") memory_kb = result.get("MemoryUsage", 0) memory_mb = memory_kb / 1024 @@ -94,13 +106,10 @@ def get_bot_status_icon(bot: dict) -> str: if not loaded: return "🔄" - if farming: return "🎴" - if idle_games and online: return "⌚" - if online: return "🟢" @@ -179,11 +188,14 @@ def get_asf_status_text() -> str: def get_farm_summary(bots: dict) -> str: total_games = 0 total_time_seconds = 0 + for bot in bots.values(): farmer = bot.get("CardsFarmer", {}) + if farmer.get("NowFarming"): games = farmer.get("CurrentGamesFarming", []) total_games += len(games) + time_str = farmer.get("TimeRemaining", "00:00:00") hours, minutes, seconds = map(int, time_str.split(":")) total_time_seconds += hours * 3600 + minutes * 60 + seconds @@ -193,6 +205,7 @@ def get_farm_summary(bots: dict) -> str: hours = total_time_seconds // 3600 minutes = (total_time_seconds % 3600) // 60 + return ( f"Игр фармится: {total_games}\n" f"Осталось времени: {hours}ч {minutes}м\n" diff --git a/bot/ui/keyboards.py b/bot/ui/keyboards.py index c4f60c7..ded018b 100644 --- a/bot/ui/keyboards.py +++ b/bot/ui/keyboards.py @@ -36,6 +36,7 @@ def back_keyboard(callback_data: str = "back") -> InlineKeyboardMarkup: def games_keyboard(bot_name: str, is_enabled: bool) -> InlineKeyboardMarkup: text = "Остановить накрутку CS2" if is_enabled else "⌚ Накрутить часы CS2" + return InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text=text, callback_data=f"farm|{bot_name}|730")], diff --git a/main.py b/main.py index 3334329..308979e 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,11 @@ import asyncio import logging from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage from aiogram.client.default import DefaultBotProperties from bot.config import BOT_TOKEN +from bot.db.init import init_db from bot.handlers.bots import router as bots_router from bot.handlers.console import router as console_router @@ -18,7 +20,7 @@ from bot.handlers.twofa import router as twofa_router logger = logging.getLogger(__name__) bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode="HTML")) # type: ignore[arg-type] -dp = Dispatcher() +dp = Dispatcher(storage=MemoryStorage()) dp.include_router(start_router) dp.include_router(navigation_router) @@ -31,6 +33,7 @@ dp.include_router(messages_router) async def main() -> None: + await init_db() await dp.start_polling(bot) @@ -43,10 +46,13 @@ if __name__ == "__main__": logging.StreamHandler(), ], ) + try: asyncio.run(main()) + except KeyboardInterrupt: logger.info("Получен сигнал остановки, бот завершает работу") + except Exception: logger.exception("Бот завершился из-за необработанной ошибки") raise diff --git a/requirements.txt b/requirements.txt index 9c46acab291e4aef3b0020e2eba7f5621da85090..1de56029974f1f061ae7d14dc0e82dc26ce289e3 100644 GIT binary patch literal 475 zcmX|8+it@k41MQY7AugX-K0@z?|Yf{0Tm^*u|$9pXIlF8vr}o0KAek>ahR_d!(y!& zKtrn>=38~MwH=uLIZv*XEo|Z9ViY1AK?`ppD#FtpbTTh^OD^+KL3U ztYiOWOTHnTLte?+0a`I5s^WauSMZ?{UXkaPks}zR{N(Bqs7blbF5)9p3GU%dU2A@X z6melQ4X>PlV{-diuWEKT^bHpz5pX@wM$Ky^9MQ8t+_fq-7w`NOWXA!FD%8eXh(h(CZzgDW_)46w;5_Gh5YgU&BKCojTESdrY?m(^7&b%t=yuPI*3@!!OGh z##z#XK5&Dz#)y|rg5+eItNT^?KL+*v_4yHb*LgefbNlk%$x-?E_lOiu>}?p7pZo)P C(UKGZ literal 914 zcmZ9L-EM+V5QO)-NgqWcX#XzyEXE?@Nfan(Q~U63zu6Tq=Hv)wXJ>b2m%rbQC2MTQ z@4-sz?VXd`lg%yTF6_}BEHIUIyd52}B$nIi4zVZRaxO6Ge(?Xm8Cd7`;=B{*-cFo$ ze`4ICS8XOmw z!e`g|>a82QfA+}0RFK&bPwri~w!jmiR4?a9XJK+hEG6ifciT}{&LsPB7G+DfbiH}j zj*P~mwd4A0hf@E_4K)e9a%RZSh^eO7E7+wnCM$0`$Hi2oG5R~A{@^;co)JzAD#D4@ zyJ_LuxSrCP9!-5qymqd_5jj!S5l(&GW7e=Wa0j1oE1&O#KH**i=L~L5x#az8 z)Diq*7RM(_H1*RsF>TURJ@z8OP{F5X?|V{s$EqS9rAl)MRkt~7*uSqmXe*Twn=O3i elaINa=|_8?_yU%r6Vn!6@JR^O(>&r!3f2Es8i{lO