From 8bd5c8069383075238747548b6171f12c77a355c Mon Sep 17 00:00:00 2001 From: root Date: Fri, 24 Jul 2026 10:44:48 +0500 Subject: [PATCH] feat(bot): add bot account ownership and balance --- .gitignore | 2 + bot/db/models.py | 33 ++++- bot/handlers/admin.py | 177 +++++++++++++++++++++++ bot/handlers/bots.py | 267 +++++++++++------------------------ bot/handlers/console.py | 17 +-- bot/handlers/inventory.py | 113 +++++---------- bot/handlers/messages.py | 20 +-- bot/handlers/navigation.py | 22 +-- bot/handlers/profile.py | 21 +++ bot/handlers/redeem.py | 21 +-- bot/handlers/start.py | 8 +- bot/handlers/twofa.py | 49 +++++-- bot/handlers/uploads.py | 190 +++++++++++++++++++++++++ bot/services/balance.py | 169 ++++++++++++++++++++++ bot/services/bot_accounts.py | 82 +++++++++++ bot/services/inventory.py | 12 +- bot/services/redeem.py | 5 +- bot/services/twofa.py | 4 +- bot/states.py | 9 ++ bot/ui/formatters.py | 21 +-- bot/ui/keyboards.py | 153 ++++++++++++-------- main.py | 6 + 22 files changed, 1008 insertions(+), 393 deletions(-) create mode 100644 bot/handlers/admin.py create mode 100644 bot/handlers/profile.py create mode 100644 bot/handlers/uploads.py create mode 100644 bot/services/balance.py create mode 100644 bot/services/bot_accounts.py diff --git a/.gitignore b/.gitignore index 7c7d8de..d76ee96 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,5 @@ pyvenv.cfg .venv pip-selfcheck.json +bot.db +v1.json diff --git a/bot/db/models.py b/bot/db/models.py index a39af60..b992f48 100644 --- a/bot/db/models.py +++ b/bot/db/models.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, Boolean, DateTime, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship from bot.db.base import Base @@ -19,9 +19,38 @@ class User(Base): 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) + balance_credits: Mapped[int] = mapped_column(BigInteger, default=0, nullable=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, ) + bots: Mapped[list["BotAccount"]] = relationship(back_populates="owner", cascade="all, delete-orphan") + + +class AppSetting(Base): + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(128), primary_key=True) + integer_value: Mapped[int] = mapped_column(BigInteger, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) + updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + + +class BotAccount(Base): + __tablename__ = "bot_accounts" + + id: Mapped[int] = mapped_column(primary_key=True) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + bot_name: Mapped[str] = mapped_column(String(255), unique=True, index=True) + steam_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + source_filename: Mapped[str] = mapped_column(String(255)) + config_sha256: Mapped[str] = mapped_column(String(64), index=True) + upload_state: Mapped[str] = mapped_column(String(32), default="attached") + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + is_attached: 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) + attached_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + owner: Mapped[User] = relationship(back_populates="bots") diff --git a/bot/handlers/admin.py b/bot/handlers/admin.py new file mode 100644 index 0000000..b0ac2f8 --- /dev/null +++ b/bot/handlers/admin.py @@ -0,0 +1,177 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from bot.config import ADMIN_ID +from bot.states import AdminFlow +from bot.filters import AdminCallbackFilter, AdminFilter +from bot.ui.keyboards import admin_keyboard, back_keyboard, admin_profile_keyboard +from bot.services.balance import ( + BalanceError, + change_user_balance, + get_bot_slot_price, + get_user_profile, + set_bot_slot_price, +) + +router = Router() + + +@router.message(Command("admin"), AdminFilter()) +async def admin_menu(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer("🤵 Админ-панель", reply_markup=admin_keyboard()) + + +@router.callback_query(lambda c: c.data == "admin_panel", AdminCallbackFilter()) +async def admin_panel(callback: CallbackQuery, state: FSMContext) -> None: + await state.clear() + + await callback.answer() + await callback.message.edit_text( + "🤵 Админ-панель", reply_markup=admin_keyboard() + ) + + +@router.callback_query( + AdminCallbackFilter(), lambda c: c.data.startswith("admin_lookup") +) +async def admin_lookup_prompt(callback: CallbackQuery, state: FSMContext) -> None: + user_id = None + try: + user_id = int(callback.data.split(":")[1]) + except: + pass + + if user_id: + try: + if user_id <= 0: + raise ValueError + _, balance, bot_count = await get_user_profile(user_id) + + except (ValueError, TypeError): + await callback.message.edit_text("❌ Некорректный Telegram ID") + return + + await callback.message.edit_text( + f"👤 Профиль\n\n" + f"🆔: {user_id}\n" + f"💰 Баланс: {balance} ₽\n" + f"🤖 Ботов: {bot_count}", + reply_markup=admin_profile_keyboard(user_id=user_id), + ) + + return + + await callback.answer() + await callback.message.edit_text( + "🆔 Отправьте ID пользователя", + reply_markup=back_keyboard(callback_data="admin_panel"), + ) + + await state.set_state(AdminFlow.waiting_user_id) + await state.update_data(action="lookup") + + +@router.callback_query( + AdminCallbackFilter(), lambda c: c.data.startswith("admin_balance:") +) +async def admin_balance_prompt(callback: CallbackQuery, state: FSMContext) -> None: + user_id = int(callback.data.split(":")[1]) + + await callback.answer() + await callback.message.edit_text( + "💲 Отправьте сумму для изменения баланса пользователя", + reply_markup=back_keyboard(callback_data=f"admin_lookup:{user_id}"), + ) + + await state.set_state(AdminFlow.waiting_amount) + await state.update_data(action="balance", user_id=user_id) + + +@router.callback_query(AdminCallbackFilter(), lambda c: c.data == "admin_price") +async def admin_price_prompt(callback: CallbackQuery, state: FSMContext) -> None: + await callback.answer() + await callback.message.edit_text( + f"💲 Текущая цена слота: {await get_bot_slot_price()} ₽\n" + "Отправьте новую положительную цену.", + reply_markup=back_keyboard(callback_data="admin_panel"), + ) + + await state.set_state(AdminFlow.waiting_amount) + await state.update_data(action="price") + + +@router.message(AdminFlow.waiting_user_id, AdminFilter()) +async def admin_lookup(message: Message, state: FSMContext) -> None: + try: + user_id = int((message.text or "").strip()) + if user_id <= 0: + raise ValueError + _, balance, bot_count = await get_user_profile(user_id) + + except (ValueError, TypeError): + await message.answer("❌ Некорректный Telegram ID") + return + + await message.answer( + f"👤 Профиль\n\n" + f"🆔: {user_id}\n" + f"💰 Баланс: {balance} ₽\n" + f"🤖 Ботов: {bot_count}", + reply_markup=admin_profile_keyboard(user_id=user_id), + ) + + await state.clear() + + +@router.message(AdminFlow.waiting_amount, AdminFilter()) +async def admin_amount(message: Message, state: FSMContext) -> None: + data = await state.get_data() + text = (message.text or "").strip() + + try: + if data.get("action") == "price": + price = int(text) + await set_bot_slot_price(price, updated_by=ADMIN_ID) + response = f"✅ Цена слота обновлена: {price} ₽" + + else: + amount_text = text + user_text = data.get("user_id", None) + + print(amount_text, user_text) + + user_id, signed_amount = int(user_text), int(amount_text) + + if user_id <= 0 or signed_amount == 0: + raise ValueError + + balance = await change_user_balance( + telegram_id=user_id, + amount=signed_amount, + actor_telegram_id=message.from_user.id, + ) + + response = f"✅ Баланс пользователя обновлён: {balance} ₽" + + except (ValueError, TypeError, BalanceError): + response = "❌ Некорректные данные: нужны положительные целые числа" + + await message.answer(response) + + if data.get("action") == "price": + return await admin_menu(message, state) + + _, balance, bot_count = await get_user_profile(user_id) + + await message.answer( + f"👤 Профиль\n\n" + f"🆔: {user_id}\n" + f"💰 Баланс: {balance} ₽\n" + f"🤖 Ботов: {bot_count}", + reply_markup=admin_profile_keyboard(user_id=user_id), + ) + + await state.clear() diff --git a/bot/handlers/bots.py b/bot/handlers/bots.py index 6411fa1..c007bb8 100644 --- a/bot/handlers/bots.py +++ b/bot/handlers/bots.py @@ -1,224 +1,125 @@ -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_bot_reloading, is_idle_enabled, update_idle_game +from bot.logging_utils import get_logger +from bot.services.bot_accounts import ( + ensure_admin_compatibility, + get_owned_bot, + list_user_bot_accounts, +) +from bot.config import ADMIN_ID 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 +from bot.services.bots import is_bot_reloading, is_idle_enabled, update_idle_game router = Router() logger = get_logger(__name__) -@router.callback_query(lambda c: c.data == "bot_loading", AdminCallbackFilter()) +async def owned_callback_bot(callback: CallbackQuery, prefix: str): + try: + account_id = int((callback.data or "").removeprefix(prefix)) + except ValueError: + return None + + return await get_owned_bot(callback.from_user.id, account_id) + + +@router.callback_query(lambda c: c.data == "bot_loading") async def bot_loading_handler(callback: CallbackQuery) -> None: - logger.info( - "Пользователь %s нажал на еще не загруженного бота", - describe_user(callback.from_user), - ) await callback.answer( - "Аккаунт еще загружается. Попробуйте через несколько секунд.", - show_alert=True, + "Аккаунт ещё загружается. Попробуйте через несколько секунд.", show_alert=True ) -@router.callback_query(lambda c: c.data == "bots", AdminCallbackFilter()) +@router.callback_query(lambda c: c.data == "bots") async def bots_handler(callback: CallbackQuery) -> None: - logger.info( - "Пользователь %s открыл список ботов", - describe_user(callback.from_user), - ) await stop_twofa_task(callback.message.message_id) + response = get_all_bots() await callback.answer() - try: - response = get_all_bots() - if response.status_code != 200: - logger.warning( - "Не удалось загрузить список ботов для %s: HTTP %s", - describe_user(callback.from_user), - response.status_code, - ) - 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 вернул ошибку") - return - - bots = data.get("Result", {}) - logger.info( - "Список ботов для %s загружен: %s бот(ов)", - describe_user(callback.from_user), - len(bots), - ) - text = f"🤖 Ботов всего: {len(bots)}\n\n{get_farm_summary(bots)}" - - 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}") - - -@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) - - if not callback.data: - logger.debug( - "Колбэк выбора бота пришел без data от %s", - describe_user(callback.from_user), - ) - await callback.answer() + if response.status_code != 200 or not response.json().get("Success"): + await callback.message.edit_text("Не удалось загрузить список ботов") 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 + asf_bots = response.json().get("Result", {}) + if callback.from_user.id == ADMIN_ID: + await ensure_admin_compatibility(list(asf_bots)) - try: - data = get_all_bots().json() - bots = data.get("Result", {}) - bot = bots.get(bot_name) - if not bot or not bot.get("BotName") or not bot.get("SteamID"): - logger.info( - "Бот %s еще не готов к отображению для %s", - bot_name, - describe_user(callback.from_user), - ) - await callback.answer( - "Аккаунт еще загружается. Попробуйте через несколько секунд.", - show_alert=True, - ) - return - logger.info( - "Открыта карточка бота %s для %s", - bot_name, - describe_user(callback.from_user), - ) - 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}") - - -@router.callback_query( - lambda c: c.data and c.data.startswith("games_"), AdminCallbackFilter() -) -async def games(callback: CallbackQuery) -> None: - await callback.answer() - if not callback.data: - logger.debug( - "Колбэк выбора игры пришел без data от %s", - describe_user(callback.from_user), - ) - return - - bot_name = callback.data.replace("games_", "") - idle_enabled = is_idle_enabled(bot_name, 730) - logger.info( - "Пользователь %s открыл меню накрутки часов для бота %s. CS2=%s", - describe_user(callback.from_user), - bot_name, - idle_enabled, - ) + accounts = await list_user_bot_accounts(callback.from_user.id) + rows = [ + (account.id, account.bot_name, asf_bots.get(account.bot_name, {})) + for account in accounts + ] await callback.message.edit_text( - "⌚ Выберите игру для накрутки часов", - reply_markup=games_keyboard(bot_name, idle_enabled), + f"🤖 Ваших ботов: {len(rows)}\n\n{get_farm_summary({name: bot for _, name, bot in rows})}", + reply_markup=bots_keyboard(rows), ) @router.callback_query( - lambda c: c.data and c.data.startswith("farm|"), AdminCallbackFilter() + lambda c: c.data and c.data.startswith("bot_") and c.data != "bot_loading" ) -async def farm_game(callback: CallbackQuery) -> None: +async def bot_selected(callback: CallbackQuery) -> None: + account = await owned_callback_bot(callback, "bot_") + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True) + return + await stop_twofa_task(callback.message.message_id) + if is_bot_reloading(account.bot_name): + await callback.answer( + "Аккаунт перезагружается. Попробуйте позже.", show_alert=True + ) + return + bot = get_all_bots().json().get("Result", {}).get(account.bot_name) + if not bot or not bot.get("BotName") or not bot.get("SteamID"): + await callback.answer( + "Аккаунт ещё загружается. Попробуйте позже.", show_alert=True + ) + return await callback.answer() - if not callback.data: - logger.debug( - "Колбэк изменения фарма пришел без data от %s", - describe_user(callback.from_user), - ) - return - - _, bot_name, app_id_value = callback.data.split("|") - logger.info( - "Пользователь %s меняет игру для накрутки на боте %s: app_id=%s", - describe_user(callback.from_user), - bot_name, - app_id_value, + await callback.message.edit_text( + format_bot_ui(bot), + disable_web_page_preview=True, + reply_markup=bot_details_keyboard( + account.id, bool(bot.get("HasMobileAuthenticator")) + ), ) - success, text, enabled = await update_idle_game(bot_name, int(app_id_value)) - if not success: - logger.warning( - "Не удалось изменить игру для накрутки на боте %s для %s: %s", - bot_name, - describe_user(callback.from_user), - text, - ) - await callback.answer(text, show_alert=True) - return - logger.info( - "Игра для накрутки на боте %s обновлена для %s. CS2=%s", - bot_name, - describe_user(callback.from_user), - enabled, +@router.callback_query(lambda c: c.data and c.data.startswith("games_")) +async def games(callback: CallbackQuery) -> None: + account = await owned_callback_bot(callback, "games_") + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True) + return + await callback.answer() + enabled = is_idle_enabled(account.bot_name, 730) + await callback.message.edit_text( + "⌚ Выберите игру для накрутки часов", + reply_markup=games_keyboard(account.id, enabled), ) - await callback.answer(text, show_alert=True) + +@router.callback_query(lambda c: c.data and c.data.startswith("farm|")) +async def farm_game(callback: CallbackQuery) -> None: try: + _, account_id, app_id = (callback.data or "").split("|") + account = await get_owned_bot(callback.from_user.id, int(account_id)) + app_id = int(app_id) + except (ValueError, TypeError): + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True) + return + success, text, enabled = await update_idle_game(account.bot_name, app_id) + await callback.answer(text, show_alert=True) + if success: await callback.message.edit_text( - text=f"⌚ Выберите игру для накрутки часов", - reply_markup=games_keyboard(bot_name, enabled), - ) - - except Exception: - logger.debug( - "Не удалось обновить клавиатуру игр для бота %s", bot_name, exc_info=True + "⌚ Выберите игру для накрутки часов", + reply_markup=games_keyboard(account.id, enabled), ) diff --git a/bot/handlers/console.py b/bot/handlers/console.py index fbeb465..0212fb1 100644 --- a/bot/handlers/console.py +++ b/bot/handlers/console.py @@ -2,8 +2,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram.fsm.context import FSMContext -from bot.filters import AdminCallbackFilter from bot.states import ConsoleFlow +from bot.filters import AdminFilter 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 @@ -12,7 +12,7 @@ router = Router() logger = get_logger(__name__) -@router.callback_query(lambda c: c.data == "console", AdminCallbackFilter()) +@router.callback_query(lambda c: c.data == "console", AdminFilter()) async def console_enter(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Пользователь вошел в режим консоли ASF: %s", user) @@ -23,16 +23,3 @@ async def console_enter(callback: CallbackQuery, state: FSMContext) -> None: 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, state: FSMContext) -> None: - user = describe_user(callback.from_user.id) - logger.info("Пользователь вышел из режима консоли ASF: %s", user) - - await state.clear() - - await callback.answer() - 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 2cf495e..6dadad2 100644 --- a/bot/handlers/inventory.py +++ b/bot/handlers/inventory.py @@ -1,102 +1,61 @@ from aiogram import Router from aiogram.types import CallbackQuery -from bot.filters import AdminCallbackFilter -from bot.logging_utils import describe_user, get_logger +from bot.services.bot_accounts import get_owned_bot from bot.services.inventory import build_inventory_menu, render_inventory_page from bot.ui.keyboards import back_keyboard router = Router() -logger = get_logger(__name__) -@router.callback_query( - lambda c: c.data and c.data.startswith("inventory_"), AdminCallbackFilter() -) +async def account_from_data(callback: CallbackQuery, prefix: str): + try: + return await get_owned_bot(callback.from_user.id, int((callback.data or "").removeprefix(prefix))) + except ValueError: + return None + + +@router.callback_query(lambda c: c.data and c.data.startswith("inventory_")) async def inventory_menu(callback: CallbackQuery) -> None: - user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inventory_", "") - logger.info("Открыто меню инвентаря: %s bot=%s", user, bot_name) - + account = await account_from_data(callback, "inventory_") + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return await callback.answer() - 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}") - + text, keyboard = await build_inventory_menu(account.bot_name, account.id) await callback.message.edit_text(text, reply_markup=keyboard) -@router.callback_query( - lambda c: c.data and c.data.startswith("inv_cs2_"), AdminCallbackFilter() -) -async def cs2_inventory(callback: CallbackQuery) -> None: - user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inv_cs2_", "") - logger.info("Открыт CS2-инвентарь: %s bot=%s", user, bot_name) - +@router.callback_query(lambda c: c.data and c.data.startswith("inv_cs2_")) +async def inventory_cs2(callback: CallbackQuery) -> None: + account = await account_from_data(callback, "inv_cs2_") + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return await callback.answer() - 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("Не удалось загрузить инвентарь") - return - + text, keyboard = await render_inventory_page(account.bot_name, "cs2", 730, 2, 0, account.id) await callback.message.edit_text(text, reply_markup=keyboard) -@router.callback_query( - lambda c: c.data and c.data.startswith("inv_steam_"), AdminCallbackFilter() -) -async def steam_inventory(callback: CallbackQuery) -> None: - user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("inv_steam_", "") - logger.info("Открыт Steam-инвентарь: %s bot=%s", user, bot_name) - +@router.callback_query(lambda c: c.data and c.data.startswith("inv_steam_")) +async def inventory_steam(callback: CallbackQuery) -> None: + account = await account_from_data(callback, "inv_steam_") + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return await callback.answer() - 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("Не удалось загрузить инвентарь") - return - + text, keyboard = await render_inventory_page(account.bot_name, "steam", 753, 6, 0, account.id) await callback.message.edit_text(text, reply_markup=keyboard) -@router.callback_query( - lambda c: c.data and c.data.startswith("invpage|"), AdminCallbackFilter() -) +@router.callback_query(lambda c: c.data and c.data.startswith("invpage|")) async def inventory_page(callback: CallbackQuery) -> None: - user = describe_user(callback.from_user.id) - _, inv_type, bot_name, page_value = callback.data.split("|") - logger.info( - "Открыта страница инвентаря: %s bot=%s type=%s page=%s", - user, - bot_name, - inv_type, - page_value, - ) + try: + _, inv_type, account_id, page = (callback.data or "").split("|") + account = await get_owned_bot(callback.from_user.id, int(account_id)) + page = int(page) + except (ValueError, TypeError): + account = None + if account is None or inv_type not in {"cs2", "steam"}: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return + appid, contextid = (730, 2) if inv_type == "cs2" else (753, 6) await callback.answer() - app_id, context_id = (730, 2) if inv_type == "cs2" else (753, 6) - text, keyboard = await render_inventory_page( - bot_name, inv_type, app_id, context_id, int(page_value) - ) - if text is None: - logger.warning( - "Не удалось загрузить страницу инвентаря: %s bot=%s type=%s page=%s", - user, - bot_name, - inv_type, - page_value, - ) - await callback.message.edit_text("Не удалось загрузить инвентарь") - return + text, keyboard = await render_inventory_page(account.bot_name, inv_type, appid, contextid, page, account.id) await callback.message.edit_text(text, reply_markup=keyboard) diff --git a/bot/handlers/messages.py b/bot/handlers/messages.py index 0013480..9f15ea8 100644 --- a/bot/handlers/messages.py +++ b/bot/handlers/messages.py @@ -4,7 +4,6 @@ from aiogram import Router from aiogram.types import Message from aiogram.fsm.context import FSMContext -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 ( @@ -12,6 +11,7 @@ from bot.services.redeem import ( redeem_all_bots_keys, redeem_single_bot_keys, ) +from bot.services.bot_accounts import list_user_bot_accounts from bot.states import ConsoleFlow, RedeemFlow @@ -26,7 +26,7 @@ router = Router() logger = get_logger(__name__) -@router.message(ConsoleFlow.waiting_command, AdminFilter()) +@router.message(ConsoleFlow.waiting_command) async def handle_console_message(message: Message, state: FSMContext): if not message.text: await message.answer("Отправьте текстовую команду.") @@ -97,11 +97,12 @@ async def handle_console_message(message: Message, state: FSMContext): await message.answer(f"Ошибка: {error}") -@router.message(RedeemFlow.waiting_bot_keys, AdminFilter()) +@router.message(RedeemFlow.waiting_bot_keys) async def handle_single_bot_redeem(message: Message, state: FSMContext): state_data = await state.get_data() bot_name = state_data.get("bot_name") + account_id = state_data.get("account_id") if not isinstance(bot_name, str) or not bot_name: await message.answer("Контекст потерян, попробуйте еще раз.") await state.clear() @@ -126,7 +127,7 @@ async def handle_single_bot_redeem(message: Message, state: FSMContext): await message.bot.edit_message_text( text=format_bot_ui(bot_data), reply_markup=bot_details_keyboard( - bot_name, bool(bot_data.get("HasMobileAuthenticator")) + account_id, bool(bot_data.get("HasMobileAuthenticator")) ), disable_web_page_preview=True, chat_id=chat_id, @@ -195,7 +196,7 @@ async def handle_single_bot_redeem(message: Message, state: FSMContext): await state.clear() -@router.message(RedeemFlow.waiting_all_keys, AdminFilter()) +@router.message(RedeemFlow.waiting_all_keys) async def handle_all_bots_redeem(message: Message, state: FSMContext): state_data = await state.get_data() @@ -210,8 +211,8 @@ async def handle_all_bots_redeem(message: Message, state: FSMContext): if menu_message_id: try: await message.bot.edit_message_text( - text=get_asf_status_text(), - reply_markup=main_keyboard(), + text=get_asf_status_text(message.from_user.id), + reply_markup=main_keyboard(message.from_user.id), chat_id=chat_id, message_id=menu_message_id, ) @@ -256,7 +257,10 @@ async def handle_all_bots_redeem(message: Message, state: FSMContext): ) try: - await checking.edit_text(await redeem_all_bots_keys(keys)) + accounts = await list_user_bot_accounts(message.from_user.id) + await checking.edit_text( + await redeem_all_bots_keys(keys, [account.bot_name for account in accounts]) + ) except Exception as error: logger.exception( diff --git a/bot/handlers/navigation.py b/bot/handlers/navigation.py index d060be5..f89813a 100644 --- a/bot/handlers/navigation.py +++ b/bot/handlers/navigation.py @@ -4,7 +4,7 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram.fsm.context import FSMContext -from bot.filters import AdminCallbackFilter +from bot.filters import AdminCallbackFilter, AdminFilter from bot.logging_utils import describe_user, get_logger from bot.api.asf import get_plugins, restart_asf as restart_asf_request @@ -18,7 +18,7 @@ router = Router() logger = get_logger(__name__) -@router.callback_query(lambda c: c.data == "back", AdminCallbackFilter()) +@router.callback_query(lambda c: c.data == "back") async def back_button(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Нажата кнопка возврата: %s", user) @@ -29,7 +29,8 @@ async def back_button(callback: CallbackQuery, state: FSMContext) -> None: await callback.answer() try: await callback.message.edit_text( - get_asf_status_text(), reply_markup=main_keyboard() + get_asf_status_text(callback.from_user.id), + reply_markup=main_keyboard(callback.from_user.id), ) logger.info("Пользователь возвращен в главное меню: %s", user) @@ -46,7 +47,8 @@ async def refresh_handler(callback: CallbackQuery) -> None: try: await callback.message.edit_text( - get_asf_status_text(), reply_markup=main_keyboard() + get_asf_status_text(callback.from_user.id), + reply_markup=main_keyboard(callback.from_user.id), ) logger.info("Главный экран обновлен: %s", user) @@ -55,7 +57,7 @@ async def refresh_handler(callback: CallbackQuery) -> None: await callback.message.edit_text(f"Ошибка: {error}") -@router.callback_query(lambda c: c.data == "delete_msg", AdminCallbackFilter()) +@router.callback_query(lambda c: c.data == "delete_msg") async def delete_message(callback: CallbackQuery) -> None: user = describe_user(callback.from_user.id) logger.info("Запрошено удаление сообщения: %s", user) @@ -77,7 +79,7 @@ async def restart_asf_confirm(callback: CallbackQuery) -> None: await callback.answer() await callback.message.edit_text( "♻ Точно перезапустить ASF?\n\nВо время перезапуска IPC будет недоступен несколько секунд.", - reply_markup=confirm_action_keyboard("restart_asf", "back"), + reply_markup=confirm_action_keyboard("restart_asf", "admin_panel"), ) @@ -99,7 +101,7 @@ async def restart_asf(callback: CallbackQuery) -> None: ) await callback.message.edit_text( f"Ошибка HTTP {response.status_code}", - reply_markup=main_keyboard(), + reply_markup=main_keyboard(callback.from_user.id), ) return @@ -115,8 +117,8 @@ async def restart_asf(callback: CallbackQuery) -> None: await asyncio.sleep(1) await callback.message.edit_text( - f"ASF успешно перезапущен\n\n{get_asf_status_text()}", - reply_markup=main_keyboard(), + f"ASF успешно перезапущен\n\n{get_asf_status_text(callback.from_user.id)}", + reply_markup=main_keyboard(callback.from_user.id), ) logger.info("ASF успешно перезапущен: %s", user) @@ -126,7 +128,7 @@ async def restart_asf(callback: CallbackQuery) -> None: await callback.message.edit_text( f"Ошибка: {error}", - reply_markup=main_keyboard(), + reply_markup=main_keyboard(callback.from_user.id), ) diff --git a/bot/handlers/profile.py b/bot/handlers/profile.py new file mode 100644 index 0000000..61e9cf3 --- /dev/null +++ b/bot/handlers/profile.py @@ -0,0 +1,21 @@ +from aiogram import Router +from aiogram.types import CallbackQuery + +from bot.services.balance import get_user_profile +from bot.ui.keyboards import back_keyboard + +router = Router() + + +@router.callback_query(lambda c: c.data == "profile") +async def profile_handler(callback: CallbackQuery) -> None: + telegram_id, balance, bot_count = await get_user_profile(callback.from_user.id) + + await callback.answer() + await callback.message.edit_text( + f"👤 Профиль\n\n" + f"🆔: {telegram_id}\n" + f"💰 Баланс: {balance} ₽\n" + f"🤖 Ботов: {bot_count}", + reply_markup=back_keyboard(), + ) diff --git a/bot/handlers/redeem.py b/bot/handlers/redeem.py index 0e0b3ba..11cab29 100644 --- a/bot/handlers/redeem.py +++ b/bot/handlers/redeem.py @@ -2,8 +2,8 @@ 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.services.bot_accounts import get_owned_bot from bot.states import RedeemFlow @@ -13,7 +13,7 @@ router = Router() logger = get_logger(__name__) -@router.callback_query(lambda c: c.data == "redeem_keys", AdminCallbackFilter()) +@router.callback_query(lambda c: c.data == "redeem_keys") async def redeem_keys_menu(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) logger.info("Открыт режим активации ключей по всем ботам: %s", user) @@ -30,13 +30,18 @@ async def redeem_keys_menu(callback: CallbackQuery, state: FSMContext) -> None: 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() -) +@router.callback_query(lambda c: c.data and c.data.startswith("redeem_bot_")) async def redeem_bot_menu(callback: CallbackQuery, state: FSMContext) -> None: user = describe_user(callback.from_user.id) - bot_name = callback.data.replace("redeem_bot_", "") + try: + account = await get_owned_bot(callback.from_user.id, int(callback.data.replace("redeem_bot_", ""))) + except ValueError: + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True) + return + bot_name = account.bot_name logger.info( "Открыт режим активации ключей для одного бота: %s bot=%s", user, bot_name ) @@ -45,9 +50,9 @@ async def redeem_bot_menu(callback: CallbackQuery, state: FSMContext) -> None: await callback.message.edit_text( f"Отправьте ключ Steam для активации на аккаунте:\n{bot_name}\n\n" "Можно отправить сразу несколько ключей.", - reply_markup=back_keyboard(f"bot_{bot_name}"), + reply_markup=back_keyboard(f"bot_{account.id}"), ) await state.set_state(RedeemFlow.waiting_bot_keys) await state.update_data( - bot_name=bot_name, menu_message_id=callback.message.message_id + bot_name=bot_name, account_id=account.id, menu_message_id=callback.message.message_id ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py index f366af2..a3ce6b3 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -2,7 +2,6 @@ from aiogram import Router from aiogram.types import Message from aiogram.filters import Command -from bot.filters import AdminFilter from bot.services.users import upsert_user_from_telegram from bot.ui.keyboards import main_keyboard @@ -14,7 +13,7 @@ router = Router() logger = get_logger(__name__) -@router.message(Command("start"), AdminFilter()) +@router.message(Command("start")) 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) @@ -28,7 +27,10 @@ async def start_handler(message: Message) -> None: logger.exception("Не удалось удалить сообщение /start: %s", user) try: - await message.answer(get_asf_status_text(), reply_markup=main_keyboard()) + await message.answer( + get_asf_status_text(message.from_user.id), + reply_markup=main_keyboard(message.from_user.id), + ) logger.info("Главное меню отправлено: %s", user) except Exception as error: diff --git a/bot/handlers/twofa.py b/bot/handlers/twofa.py index 483d40d..745c271 100644 --- a/bot/handlers/twofa.py +++ b/bot/handlers/twofa.py @@ -4,7 +4,7 @@ from aiogram import Router from aiogram.types import CallbackQuery from bot.states import twofa_tasks -from bot.filters import AdminCallbackFilter +from bot.services.bot_accounts import get_owned_bot from bot.logging_utils import describe_user, get_logger from bot.services.twofa import auto_update_2fa, stop_twofa_task from bot.api.asf import accept_confirmations, get_confirmations @@ -18,7 +18,6 @@ logger = get_logger(__name__) lambda c: c.data and c.data.startswith("2fa_") and not c.data.startswith("2fa_refresh_"), - AdminCallbackFilter(), ) async def twofa_handler(callback: CallbackQuery) -> None: await callback.answer() @@ -28,7 +27,13 @@ async def twofa_handler(callback: CallbackQuery) -> None: describe_user(callback.from_user), ) return - bot_name = callback.data.replace("2fa_", "") + try: + account = await get_owned_bot(callback.from_user.id, int(callback.data.replace("2fa_", ""))) + except ValueError: + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return + bot_name = account.bot_name message_id = callback.message.message_id logger.info( "Пользователь %s открыл 2FA для бота %s", @@ -38,9 +43,9 @@ async def twofa_handler(callback: CallbackQuery) -> None: await stop_twofa_task(message_id) await callback.message.edit_text( f"🔐 {bot_name}\n\nЗагрузка 2FA...", - reply_markup=back_keyboard(f"bot_{bot_name}"), + reply_markup=back_keyboard(f"bot_{account.id}"), ) - task = asyncio.create_task(auto_update_2fa(callback.message, bot_name)) + task = asyncio.create_task(auto_update_2fa(callback.message, bot_name, account.id)) twofa_tasks[message_id] = task logger.info( "Запущено фоновое обновление 2FA для бота %s в сообщении %s", @@ -52,13 +57,19 @@ async def twofa_handler(callback: CallbackQuery) -> None: @router.callback_query( lambda c: c.data and c.data.startswith("confirm_") + and c.data.removeprefix("confirm_").isdigit() and not c.data.startswith("confirm_list_") and not c.data.startswith("confirm_all_"), - AdminCallbackFilter(), ) async def confirm_trades(callback: CallbackQuery) -> None: await callback.answer() - bot_name = callback.data.replace("confirm_", "") + try: + account = await get_owned_bot(callback.from_user.id, int(callback.data.replace("confirm_", ""))) + except ValueError: + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return + bot_name = account.bot_name logger.info( "Пользователь %s подтвердил сделки для бота %s", describe_user(callback.from_user), @@ -86,7 +97,7 @@ async def confirm_trades(callback: CallbackQuery) -> None: @router.callback_query( - lambda c: c.data and c.data.startswith("confirm_list_"), AdminCallbackFilter() + lambda c: c.data and c.data.startswith("confirm_list_") ) async def confirm_list(callback: CallbackQuery) -> None: await stop_twofa_task(callback.message.message_id) @@ -97,7 +108,13 @@ async def confirm_list(callback: CallbackQuery) -> None: describe_user(callback.from_user), ) return - bot_name = callback.data.replace("confirm_list_", "") + try: + account = await get_owned_bot(callback.from_user.id, int(callback.data.replace("confirm_list_", ""))) + except ValueError: + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return + bot_name = account.bot_name logger.info( "Пользователь %s открыл список подтверждений для бота %s", describe_user(callback.from_user), @@ -118,7 +135,7 @@ async def confirm_list(callback: CallbackQuery) -> None: 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) + text, reply_markup=confirmations_keyboard(account.id) ) except Exception as error: logger.exception( @@ -129,7 +146,7 @@ async def confirm_list(callback: CallbackQuery) -> None: @router.callback_query( - lambda c: c.data and c.data.startswith("confirm_all_"), AdminCallbackFilter() + lambda c: c.data and c.data.startswith("confirm_all_") ) async def confirm_all(callback: CallbackQuery) -> None: await stop_twofa_task(callback.message.message_id) @@ -142,7 +159,13 @@ async def confirm_all(callback: CallbackQuery) -> None: ) return - bot_name = callback.data.replace("confirm_all_", "") + try: + account = await get_owned_bot(callback.from_user.id, int(callback.data.replace("confirm_all_", ""))) + except ValueError: + account = None + if account is None: + await callback.answer("Бот не найден или вам не принадлежит", show_alert=True); return + bot_name = account.bot_name logger.info( "Пользователь %s запустил подтверждение всех сделок для бота %s", describe_user(callback.from_user), @@ -175,7 +198,7 @@ async def confirm_all(callback: CallbackQuery) -> None: text += f"- {conf['type_name']}\nID: {conf['id']}\n\n" await callback.message.edit_text( - text, reply_markup=confirmations_keyboard(bot_name) + text, reply_markup=confirmations_keyboard(account.id) ) await callback.answer("Все подтверждено", show_alert=True) diff --git a/bot/handlers/uploads.py b/bot/handlers/uploads.py new file mode 100644 index 0000000..0cd7cec --- /dev/null +++ b/bot/handlers/uploads.py @@ -0,0 +1,190 @@ +import asyncio +import hashlib +import io +import json +import posixpath +import re +import zipfile +from datetime import datetime, timezone +from pathlib import PurePosixPath + +from aiogram import Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from bot.api.asf import get_bot, save_bot_config +from bot.services.bot_accounts import ( + bot_name_exists, + create_bot_account, + user_hash_exists, +) +from bot.services.balance import ( + InsufficientBalance, + credit_user, + debit_user, + get_bot_slot_price, +) +from bot.states import UploadFlow +from bot.ui.keyboards import back_keyboard + +router = Router() +_groups: dict[str, list[Message]] = {} +_group_tasks: dict[str, asyncio.Task] = {} + + +@router.callback_query(lambda c: c.data == "upload") +async def upload_menu(callback: CallbackQuery, state: FSMContext) -> None: + await callback.answer() + await state.set_state(UploadFlow.waiting_upload) + await callback.message.edit_text( + "Отправьте JSON-конфиг, несколько JSON одним альбомом или ZIP-архив с JSON-файлами.", + reply_markup=back_keyboard(callback_data="bots"), + ) + + +def _decode_config(raw: bytes) -> dict: + value = json.loads(raw.decode("utf-8-sig")) + if not isinstance(value, dict): + raise ValueError("JSON должен содержать объект конфигурации") + + return value + + +def _zip_entries(raw: bytes) -> list[tuple[str, bytes]]: + entries = [] + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + for item in archive.infolist(): + name = item.filename.replace("\\", "/") + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise ValueError("ZIP содержит небезопасный путь") + if item.is_dir() or not name.lower().endswith(".json"): + continue + entries.append((name, archive.read(item))) + return entries + + +async def _download(message: Message) -> bytes: + buffer = io.BytesIO() + await message.bot.download(message.document.file_id, destination=buffer) + return buffer.getvalue() + + +async def _process(user_id: int, files: list[tuple[str, bytes]]) -> str: + results = [] + seen_hashes: set[str] = set() + seen_names: set[str] = set() + + for filename, raw in files: + try: + config = _decode_config(raw) + digest = hashlib.sha256(raw).hexdigest() + name = config.get("SteamLogin") + + if name in seen_names or await bot_name_exists(name): + raise ValueError("бот с таким именем уже существует") + + if digest in seen_hashes or await user_hash_exists(user_id, digest): + raise ValueError("такой конфиг уже загружался") + + existing = get_bot(name) + if existing.status_code == 200 and existing.json().get("Result"): + raise ValueError("бот с таким именем уже существует в ASF") + + slot_price = await get_bot_slot_price() + await debit_user(user_id, slot_price) + try: + response = save_bot_config(name, config) + if response.status_code != 200 or not response.json().get("Success"): + raise ValueError( + f"ASF отклонил конфиг (HTTP {response.status_code})" + ) + + await create_bot_account( + user_id, + bot_name=name, + steam_id=( + str(config.get("SteamID")) if config.get("SteamID") else None + ), + source_filename=filename[:255], + config_sha256=digest, + upload_state="attached", + is_attached=True, + attached_at=datetime.now(timezone.utc), + ) + + except Exception: + await credit_user(user_id, slot_price) + raise + + seen_names.add(name) + seen_hashes.add(digest) + results.append(f"✅ {filename}") + + except ( + InsufficientBalance, + ValueError, + json.JSONDecodeError, + UnicodeDecodeError, + zipfile.BadZipFile, + ) as error: + results.append(f"❌ {filename}: {error}") + + except Exception: + results.append(f"❌ {filename}: внутренняя ошибка") + + return f"Обработано файлов: {len(files)}" + + +async def _finish_group(key: str, state: FSMContext) -> None: + await asyncio.sleep(0.8) + messages = _groups.pop(key, []) + _group_tasks.pop(key, None) + if not messages: + return + files = [] + for message in messages: + try: + files.append( + (message.document.file_name or "config.json", await _download(message)) + ) + except Exception: + files.append((message.document.file_name or "config.json", b"")) + + await messages[0].answer(await _process(messages[0].from_user.id, files)) + await state.clear() + + +@router.message(UploadFlow.waiting_upload, lambda message: bool(message.document)) +async def upload_document(message: Message, state: FSMContext) -> None: + document = message.document + filename = document.file_name or "config.json" + + if message.media_group_id: + key = f"{message.chat.id}:{message.media_group_id}" + _groups.setdefault(key, []).append(message) + if key not in _group_tasks: + _group_tasks[key] = asyncio.create_task(_finish_group(key, state)) + return + + raw = await _download(message) + if filename.lower().endswith(".zip"): + try: + files = _zip_entries(raw) + + except Exception as error: + await message.answer(f"❌ {filename}: {error}") + await state.clear() + return + + if not files: + await message.answer("❌ В архиве нет JSON-файлов") + else: + await message.answer(await _process(message.from_user.id, files)) + + elif filename.lower().endswith(".json"): + await message.answer(await _process(message.from_user.id, [(filename, raw)])) + else: + await message.answer("❌ Поддерживаются только .json и .zip") + + await state.clear() diff --git a/bot/services/balance.py b/bot/services/balance.py new file mode 100644 index 0000000..2a770e3 --- /dev/null +++ b/bot/services/balance.py @@ -0,0 +1,169 @@ +from sqlalchemy import func, select, update + +from bot.config import ADMIN_ID +from bot.db.models import AppSetting, BotAccount, User +from bot.db.session import async_session + +BOT_SLOT_PRICE_KEY = "bot_slot_price" +DEFAULT_BOT_SLOT_PRICE = 1 + + +class BalanceError(ValueError): + pass + + +class InsufficientBalance(BalanceError): + pass + + +def _amount(value: int) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise BalanceError("Amount must be a positive integer") + return value + + +async def _get_or_create_user(session, telegram_id: int) -> User: + user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) + if user is None: + user = User(telegram_id=telegram_id, balance_credits=0) + session.add(user) + await session.flush() + return user + + +async def get_user_balance(telegram_id: int) -> int: + async with async_session() as session: + user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) + return int(user.balance_credits) if user else 0 + + +async def change_user_balance( + telegram_id: int, amount: int, *, actor_telegram_id: int | None = None +) -> int: + amount = _amount(amount) + if ( + actor_telegram_id is not None + and telegram_id != actor_telegram_id + and actor_telegram_id != ADMIN_ID + ): + raise PermissionError("Only an administrator can adjust another user") + + async with async_session() as session: + user = await _get_or_create_user(session, telegram_id) + await session.execute( + update(User).where(User.id == user.id).values(balance_credits=amount) + ) + + await session.commit() + return int( + ( + await session.scalar( + select(User.balance_credits).where(User.id == user.id) + ) + ) + or 0 + ) + + +async def credit_user( + telegram_id: int, amount: int, *, actor_telegram_id: int | None = None +) -> int: + amount = _amount(amount) + if ( + actor_telegram_id is not None + and telegram_id != actor_telegram_id + and actor_telegram_id != ADMIN_ID + ): + raise PermissionError("Only an administrator can adjust another user") + + async with async_session() as session: + user = await _get_or_create_user(session, telegram_id) + await session.execute( + update(User) + .where(User.id == user.id) + .values(balance_credits=User.balance_credits + amount) + ) + await session.commit() + return int( + ( + await session.scalar( + select(User.balance_credits).where(User.id == user.id) + ) + ) + or 0 + ) + + +async def debit_user( + telegram_id: int, amount: int, *, actor_telegram_id: int | None = None +) -> int: + amount = _amount(amount) + if ( + actor_telegram_id is not None + and telegram_id != actor_telegram_id + and actor_telegram_id != ADMIN_ID + ): + raise PermissionError("Only an administrator can adjust another user") + + async with async_session() as session: + user = await _get_or_create_user(session, telegram_id) + result = await session.execute( + update(User) + .where(User.id == user.id, User.balance_credits >= amount) + .values(balance_credits=User.balance_credits - amount) + ) + if result.rowcount != 1: + await session.rollback() + raise InsufficientBalance("Insufficient balance") + await session.commit() + return int( + ( + await session.scalar( + select(User.balance_credits).where(User.id == user.id) + ) + ) + or 0 + ) + + +async def get_bot_slot_price() -> int: + async with async_session() as session: + setting = await session.get(AppSetting, BOT_SLOT_PRICE_KEY) + if setting is None: + setting = AppSetting( + key=BOT_SLOT_PRICE_KEY, integer_value=DEFAULT_BOT_SLOT_PRICE + ) + session.add(setting) + await session.commit() + return DEFAULT_BOT_SLOT_PRICE + if setting.integer_value <= 0: + return DEFAULT_BOT_SLOT_PRICE + return int(setting.integer_value) + + +async def set_bot_slot_price(price: int, *, updated_by: int) -> int: + price = _amount(price) + async with async_session() as session: + setting = await session.get(AppSetting, BOT_SLOT_PRICE_KEY) + if setting is None: + setting = AppSetting( + key=BOT_SLOT_PRICE_KEY, integer_value=price, updated_by=updated_by + ) + session.add(setting) + else: + setting.integer_value = price + setting.updated_by = updated_by + await session.commit() + return price + + +async def get_user_profile(telegram_id: int) -> tuple[int, int, int]: + async with async_session() as session: + user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) + if user is None: + return telegram_id, 0, 0 + + count = await session.scalar( + select(func.count(BotAccount.id)).where(BotAccount.owner_id == user.id) + ) + return telegram_id, int(user.balance_credits), int(count or 0) diff --git a/bot/services/bot_accounts.py b/bot/services/bot_accounts.py new file mode 100644 index 0000000..d620a84 --- /dev/null +++ b/bot/services/bot_accounts.py @@ -0,0 +1,82 @@ +from sqlalchemy import select + +from bot.db.models import BotAccount, User +from bot.db.session import async_session +from bot.config import ADMIN_ID +import hashlib + + +async def list_user_bot_accounts(telegram_id: int) -> list[BotAccount]: + async with async_session() as session: + result = await session.execute( + select(BotAccount) + .join(User) + .where(User.telegram_id == telegram_id) + .order_by(BotAccount.bot_name) + ) + return list(result.scalars()) + + +async def get_owned_bot(telegram_id: int, account_id: int) -> BotAccount | None: + async with async_session() as session: + result = await session.execute( + select(BotAccount) + .join(User) + .where(BotAccount.id == account_id, User.telegram_id == telegram_id) + ) + return result.scalar_one_or_none() + + +async def create_bot_account(telegram_id: int, **values) -> BotAccount: + async with async_session() as session: + user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) + if user is None: + user = User(telegram_id=telegram_id) + session.add(user) + await session.flush() + account = BotAccount(owner_id=user.id, **values) + session.add(account) + await session.commit() + await session.refresh(account) + return account + + +async def bot_name_exists(bot_name: str) -> bool: + async with async_session() as session: + return ( + await session.scalar( + select(BotAccount.id).where(BotAccount.bot_name == bot_name) + ) + is not None + ) + + +async def user_hash_exists(telegram_id: int, config_sha256: str) -> bool: + async with async_session() as session: + return ( + await session.scalar( + select(BotAccount.id) + .join(User) + .where( + User.telegram_id == telegram_id, + BotAccount.config_sha256 == config_sha256, + ) + ) + is not None + ) + + +async def ensure_admin_compatibility(bot_names: list[str]) -> None: + """Keep ASF bots created before ownership was introduced visible to the admin.""" + for bot_name in bot_names: + if await bot_name_exists(bot_name): + continue + + await create_bot_account( + ADMIN_ID, + bot_name=bot_name, + source_filename="existing-asf", + config_sha256=hashlib.sha256(bot_name.encode()).hexdigest(), + upload_state="attached", + is_attached=True, + ) diff --git a/bot/services/inventory.py b/bot/services/inventory.py index 363af46..3f88d42 100644 --- a/bot/services/inventory.py +++ b/bot/services/inventory.py @@ -7,7 +7,7 @@ from bot.ui.keyboards import inventory_menu_keyboard, inventory_page_keyboard logger = get_logger(__name__) -async def build_inventory_menu(bot_name: str) -> tuple[str, object]: +async def build_inventory_menu(bot_name: str, account_id: int) -> tuple[str, object]: logger.info("Формирование меню инвентаря: bot=%s", bot_name) cs2_inventory = await get_bot_inventory(bot_name, 730, 2) steam_inventory = await get_bot_inventory(bot_name, 753, 6) @@ -25,7 +25,7 @@ async def build_inventory_menu(bot_name: str) -> tuple[str, object]: logger.info("Инвентарь пуст: bot=%s", bot_name) return ( f"📦 Инвентарь {bot_name} пуст", - inventory_menu_keyboard(bot_name, 0, 0), + inventory_menu_keyboard(account_id, 0, 0), ) logger.info( "Меню инвентаря сформировано: bot=%s cs2_items=%s steam_items=%s", @@ -40,11 +40,11 @@ async def build_inventory_menu(bot_name: str) -> tuple[str, object]: "🔒 — нельзя продавать\n" "❌ — нельзя трейдить" ) - return text, inventory_menu_keyboard(bot_name, len(cs2_assets), len(steam_assets)) + return text, inventory_menu_keyboard(account_id, len(cs2_assets), len(steam_assets)) async def render_inventory_page( - bot_name: str, inventory_type: str, appid: int, contextid: int, page: int + bot_name: str, inventory_type: str, appid: int, contextid: int, page: int, account_id: int ) -> tuple[str, object] | tuple[None, None]: logger.info( "Формирование страницы инвентаря: bot=%s type=%s appid=%s contextid=%s page=%s", @@ -70,7 +70,7 @@ async def render_inventory_page( logger.info( "Инвентарь выбранного типа пуст: bot=%s type=%s", bot_name, inventory_type ) - return f"Инвентарь {bot_name} пуст", inventory_menu_keyboard(bot_name, 0, 0) + return f"Инвентарь {bot_name} пуст", inventory_menu_keyboard(account_id, 0, 0) desc_map = {} for desc in descriptions: key = (str(desc.get("classid")), str(desc.get("instanceid"))) @@ -139,5 +139,5 @@ async def render_inventory_page( ) return text[:4000], inventory_page_keyboard( - inventory_type, bot_name, page, total_pages + inventory_type, account_id, page, total_pages ) diff --git a/bot/services/redeem.py b/bot/services/redeem.py index d703f5b..6c32350 100644 --- a/bot/services/redeem.py +++ b/bot/services/redeem.py @@ -111,11 +111,12 @@ async def redeem_single_bot_keys(bot_name: str, keys: list[str]) -> str: return text[:4000] + ("\n\n...обрезано" if len(text) > 4000 else "") -async def redeem_all_bots_keys(keys: list[str]) -> str: +async def redeem_all_bots_keys(keys: list[str], allowed_bots: list[str] | None = None) -> str: logger.info("Начата активация ключей по всем ботам: count=%s", len(keys)) response = get_all_bots() data = response.json() - bots = list(data.get("Result", {}).keys()) + available = list(data.get("Result", {}).keys()) + bots = [name for name in available if allowed_bots is None or name in allowed_bots] logger.info("Для массовой активации найдено ботов: count=%s", len(bots)) success_count = 0 failed_count = 0 diff --git a/bot/services/twofa.py b/bot/services/twofa.py index c7d08cb..faa4a46 100644 --- a/bot/services/twofa.py +++ b/bot/services/twofa.py @@ -27,7 +27,7 @@ async def stop_twofa_task(message_id: int) -> None: ) -async def auto_update_2fa(message, bot_name: str) -> None: +async def auto_update_2fa(message, bot_name: str, account_id: int) -> None: last_code = None message_id = message.message_id logger.info( @@ -45,7 +45,7 @@ async def auto_update_2fa(message, bot_name: str) -> None: try: await message.edit_text( text, - reply_markup=twofa_keyboard(bot_name, bool(confirmations)), + reply_markup=twofa_keyboard(account_id, bool(confirmations)), ) last_code = code diff --git a/bot/states.py b/bot/states.py index 9790914..d38c0f6 100644 --- a/bot/states.py +++ b/bot/states.py @@ -10,4 +10,13 @@ class RedeemFlow(StatesGroup): waiting_bot_keys = State() +class UploadFlow(StatesGroup): + waiting_upload = State() + + +class AdminFlow(StatesGroup): + waiting_user_id = State() + waiting_amount = State() + + twofa_tasks = {} diff --git a/bot/ui/formatters.py b/bot/ui/formatters.py index a8c4f37..c1134f7 100644 --- a/bot/ui/formatters.py +++ b/bot/ui/formatters.py @@ -3,6 +3,7 @@ import json from datetime import datetime, timezone from bot.api.asf import get_asf_status +from bot.config import ADMIN_ID from bot.constants import GAMES @@ -78,21 +79,23 @@ def get_uptime(start_time_str: str) -> str: return f"{days_measure}{hours_measure}{minutes_measure}{seconds_measure}" -def format_asf(data: dict) -> str: +def format_asf(data: dict, user_id: int) -> str: result = data.get("Result", {}) current_version = result.get("Version") latest_version = result.get("LatestVersion", current_version) lines = [f"Версия ASF: {current_version}"] - if current_version != latest_version: - lines.append(f"Доступно обновление: {latest_version}") - memory_kb = result.get("MemoryUsage", 0) - memory_mb = memory_kb / 1024 + if user_id == ADMIN_ID: + if current_version != latest_version: + lines.append(f"Доступно обновление: {latest_version}") - lines.append(f"Используется памяти: {memory_mb:.2f} MB") - lines.append(f"Работает: {get_uptime(result.get('ProcessStartTime'))}") + memory_kb = result.get("MemoryUsage", 0) + memory_mb = memory_kb / 1024 + + lines.append(f"Используется памяти: {memory_mb:.2f} MB") + lines.append(f"Работает: {get_uptime(result.get('ProcessStartTime'))}") return "\n".join(lines) @@ -172,7 +175,7 @@ def format_bot_ui(bot: dict) -> str: return text -def get_asf_status_text() -> str: +def get_asf_status_text(user_id: int) -> str: response = get_asf_status() if response.status_code != 200: @@ -182,7 +185,7 @@ def get_asf_status_text() -> str: if not data.get("Success"): return "ASF вернул ошибку" - return f"💻 Панель управления ASF\n\n{format_asf(data)}" + return f"💻 Панель управления ASF\n\n{format_asf(data, user_id)}" def get_farm_summary(bots: dict) -> str: diff --git a/bot/ui/keyboards.py b/bot/ui/keyboards.py index ded018b..c971726 100644 --- a/bot/ui/keyboards.py +++ b/bot/ui/keyboards.py @@ -1,30 +1,40 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from bot.ui.formatters import get_bot_status_icon +from bot.config import ADMIN_ID -def main_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[ +def main_keyboard(user_id: int | None = None) -> InlineKeyboardMarkup: + keyboard = [ + [InlineKeyboardButton(text="🤖 Боты", callback_data="bots")], + [InlineKeyboardButton(text="🔑 Активация ключей", callback_data="redeem_keys")], + ] + + profile_index = -1 if user_id == ADMIN_ID else 0 + + if user_id == ADMIN_ID: + keyboard.insert( + 0, [InlineKeyboardButton(text="🆙 Обновить", callback_data="refresh")], - [InlineKeyboardButton(text="🤖 Боты", callback_data="bots")], + ) + + keyboard.extend( [ - InlineKeyboardButton( - text="🔑 Активация ключей", - callback_data="redeem_keys", - ) + [ + InlineKeyboardButton( + text="🤵 Админ-панель", callback_data="admin_panel" + ) + ], ], - [InlineKeyboardButton(text="📁 Плагины", callback_data="plugins")], - [InlineKeyboardButton(text="💻 Консоль", callback_data="console")], - [ - InlineKeyboardButton( - text="♻ Перезапустить ASF", - callback_data="restart_asf_confirm", - ) - ], - ] + ) + + keyboard.insert( + profile_index, + [InlineKeyboardButton(text="👤 Профиль", callback_data="profile")], ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + def back_keyboard(callback_data: str = "back") -> InlineKeyboardMarkup: return InlineKeyboardMarkup( @@ -34,57 +44,96 @@ def back_keyboard(callback_data: str = "back") -> InlineKeyboardMarkup: ) -def games_keyboard(bot_name: str, is_enabled: bool) -> InlineKeyboardMarkup: +def admin_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔍 Найти пользователя", callback_data="admin_lookup" + ) + ], + [InlineKeyboardButton(text="💲 Цена слота", callback_data="admin_price")], + [InlineKeyboardButton(text="📁 Плагины", callback_data="plugins")], + [InlineKeyboardButton(text="💻 Консоль", callback_data="console")], + [ + InlineKeyboardButton( + text="♻ Перезапустить ASF", callback_data="restart_asf_confirm" + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back")], + ] + ) + + +def admin_profile_keyboard(user_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="💲 Изменить баланс", callback_data=f"admin_balance:{user_id}" + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")], + ] + ) + + +def games_keyboard(account_id: int, 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")], - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{bot_name}")], + [InlineKeyboardButton(text=text, callback_data=f"farm|{account_id}|730")], + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{account_id}")], ] ) -def bots_keyboard(bots: dict) -> InlineKeyboardMarkup: +def bots_keyboard(bots: list[tuple[int, str, dict]]) -> InlineKeyboardMarkup: keyboard = [] - for name, bot in bots.items(): + for account_id, name, bot in bots: status = get_bot_status_icon(bot) loaded = bool(bot.get("BotName")) and bool(bot.get("SteamID")) - callback_data = f"bot_{name}" if loaded else "bot_loading" + callback_data = f"bot_{account_id}" if loaded else "bot_loading" keyboard.append( [InlineKeyboardButton(text=f"{status} {name}", callback_data=callback_data)] ) - keyboard.append([InlineKeyboardButton(text="🔙 Назад", callback_data="back")]) + + keyboard.extend( + [ + [InlineKeyboardButton(text="⬆️ Загрузить конфиг", callback_data="upload")], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")], + ] + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) def bot_details_keyboard( - bot_name: str, has_mobile_authenticator: bool + account_id: int, has_mobile_authenticator: bool ) -> InlineKeyboardMarkup: keyboard = [] if has_mobile_authenticator: keyboard.append( - [InlineKeyboardButton(text="🔐 2FA", callback_data=f"2fa_{bot_name}")] + [InlineKeyboardButton(text="🔐 2FA", callback_data=f"2fa_{account_id}")] ) keyboard.append( [ InlineKeyboardButton( - text="⌚ Накрутка часов", callback_data=f"games_{bot_name}" + text="⌚ Накрутка часов", callback_data=f"games_{account_id}" ) ] ) keyboard.append( [ InlineKeyboardButton( - text="🔑 Активировать ключ", - callback_data=f"redeem_bot_{bot_name}", + text="🔑 Активировать ключ", callback_data=f"redeem_bot_{account_id}" ) ] ) keyboard.append( [ InlineKeyboardButton( - text="📦 Инвентарь", callback_data=f"inventory_{bot_name}" + text="📦 Инвентарь", callback_data=f"inventory_{account_id}" ) ] ) @@ -92,33 +141,31 @@ def bot_details_keyboard( return InlineKeyboardMarkup(inline_keyboard=keyboard) -def twofa_keyboard(bot_name: str, has_confirmations: bool) -> InlineKeyboardMarkup: +def twofa_keyboard(account_id: int, has_confirmations: bool) -> InlineKeyboardMarkup: keyboard = [] if has_confirmations: keyboard.append( [ InlineKeyboardButton( - text="Подтверждения", - callback_data=f"confirm_list_{bot_name}", + text="Подтверждения", callback_data=f"confirm_list_{account_id}" ) ] ) keyboard.append( - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{bot_name}")] + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{account_id}")] ) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def confirmations_keyboard(bot_name: str) -> InlineKeyboardMarkup: +def confirmations_keyboard(account_id: int) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="Подтвердить всё", - callback_data=f"confirm_all_{bot_name}", + text="Подтвердить всё", callback_data=f"confirm_all_{account_id}" ) ], - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"2fa_{bot_name}")], + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"2fa_{account_id}")], ] ) @@ -135,15 +182,14 @@ def confirm_action_keyboard( def inventory_menu_keyboard( - bot_name: str, cs2_count: int, steam_count: int + account_id: int, cs2_count: int, steam_count: int ) -> InlineKeyboardMarkup: keyboard = [] if cs2_count: keyboard.append( [ InlineKeyboardButton( - text=f"CS2 ({cs2_count})", - callback_data=f"inv_cs2_{bot_name}", + text=f"CS2 ({cs2_count})", callback_data=f"inv_cs2_{account_id}" ) ] ) @@ -152,40 +198,37 @@ def inventory_menu_keyboard( [ InlineKeyboardButton( text=f"Steam ({steam_count})", - callback_data=f"inv_steam_{bot_name}", + callback_data=f"inv_steam_{account_id}", ) ] ) keyboard.append( - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{bot_name}")] + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"bot_{account_id}")] ) return InlineKeyboardMarkup(inline_keyboard=keyboard) def inventory_page_keyboard( - inventory_type: str, bot_name: str, page: int, total_pages: int + inventory_type: str, account_id: int, page: int, total_pages: int ) -> InlineKeyboardMarkup: - nav_buttons = [] + nav = [] if page > 0: - nav_buttons.append( + nav.append( InlineKeyboardButton( text="◀", - callback_data=f"invpage|{inventory_type}|{bot_name}|{page - 1}", + callback_data=f"invpage|{inventory_type}|{account_id}|{page - 1}", ) ) if page < total_pages - 1: - nav_buttons.append( + nav.append( InlineKeyboardButton( text="▶", - callback_data=f"invpage|{inventory_type}|{bot_name}|{page + 1}", + callback_data=f"invpage|{inventory_type}|{account_id}|{page + 1}", ) ) - - keyboard = [] - if nav_buttons: - keyboard.append(nav_buttons) + keyboard = [nav] if nav else [] keyboard.append( - [InlineKeyboardButton(text="🔙 Назад", callback_data=f"inventory_{bot_name}")] + [InlineKeyboardButton(text="🔙 Назад", callback_data=f"inventory_{account_id}")] ) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -193,7 +236,7 @@ def inventory_page_keyboard( def console_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton(text="🔙 Назад", callback_data="console_exit")] + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")] ] ) diff --git a/main.py b/main.py index 308979e..0d0bd37 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,9 @@ from bot.handlers.navigation import router as navigation_router from bot.handlers.redeem import router as redeem_router from bot.handlers.start import router as start_router from bot.handlers.twofa import router as twofa_router +from bot.handlers.uploads import router as uploads_router +from bot.handlers.profile import router as profile_router +from bot.handlers.admin import router as admin_router logger = logging.getLogger(__name__) @@ -30,6 +33,9 @@ dp.include_router(twofa_router) dp.include_router(inventory_router) dp.include_router(redeem_router) dp.include_router(messages_router) +dp.include_router(uploads_router) +dp.include_router(profile_router) +dp.include_router(admin_router) async def main() -> None: