feat(bot): add bot account ownership and balance

This commit is contained in:
2026-07-24 10:44:48 +05:00
parent 87385228ea
commit 8bd5c80693
22 changed files with 1008 additions and 393 deletions
+2
View File
@@ -189,3 +189,5 @@ pyvenv.cfg
.venv
pip-selfcheck.json
bot.db
v1.json
+31 -2
View File
@@ -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")
+177
View File
@@ -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("🤵 <b>Админ-панель</b>", 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(
"🤵 <b>Админ-панель</b>", 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"👤 <b>Профиль</b>\n\n"
f"🆔: <code>{user_id}</code>\n"
f"💰 Баланс: <code>{balance}</code> ₽\n"
f"🤖 Ботов: <code>{bot_count}</code>",
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"💲 Текущая цена слота: <code>{await get_bot_slot_price()}</code> ₽\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"👤 <b>Профиль</b>\n\n"
f"🆔: <code>{user_id}</code>\n"
f"💰 Баланс: <code>{balance}</code> ₽\n"
f"🤖 Ботов: <code>{bot_count}</code>",
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"✅ Цена слота обновлена: <code>{price}</code> ₽"
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"✅ Баланс пользователя обновлён: <code>{balance}</code> ₽"
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"👤 <b>Профиль</b>\n\n"
f"🆔: <code>{user_id}</code>\n"
f"💰 Баланс: <code>{balance}</code> ₽\n"
f"🤖 Ботов: <code>{bot_count}</code>",
reply_markup=admin_profile_keyboard(user_id=user_id),
)
await state.clear()
+68 -167
View File
@@ -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)
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")
await callback.answer()
if response.status_code != 200 or not response.json().get("Success"):
await callback.message.edit_text("Не удалось загрузить список ботов")
return
data = response.json()
if not data.get("Success"):
logger.warning(
"ASF вернул неуспешный ответ при загрузке списка ботов для %s",
describe_user(callback.from_user),
asf_bots = response.json().get("Result", {})
if callback.from_user.id == ADMIN_ID:
await ensure_admin_compatibility(list(asf_bots))
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(
f"<b>🤖 Ваших ботов:</b> {len(rows)}\n\n{get_farm_summary({name: bot for _, name, bot in rows})}",
reply_markup=bots_keyboard(rows),
)
await callback.message.edit_text("ASF вернул ошибку")
return
bots = data.get("Result", {})
logger.info(
"Список ботов для %s загружен: %s бот(ов)",
describe_user(callback.from_user),
len(bots),
)
text = f"<b>🤖 Ботов всего:</b> {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()
lambda c: c.data and c.data.startswith("bot_") and c.data != "bot_loading"
)
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 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),
)
if is_bot_reloading(account.bot_name):
await callback.answer(
"Аккаунт перезагружается. Попробуйте через несколько секунд.",
show_alert=True,
"Аккаунт перезагружается. Попробуйте позже.", show_alert=True
)
return
try:
data = get_all_bots().json()
bots = data.get("Result", {})
bot = bots.get(bot_name)
bot = get_all_bots().json().get("Result", {}).get(account.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,
"Аккаунт ещё загружается. Попробуйте позже.", 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"))
account.id, 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()
)
@router.callback_query(lambda c: c.data and c.data.startswith("games_"))
async def games(callback: CallbackQuery) -> None:
await callback.answer()
if not callback.data:
logger.debug(
"Колбэк выбора игры пришел без data от %s",
describe_user(callback.from_user),
)
account = await owned_callback_bot(callback, "games_")
if account is None:
await callback.answer("Бот не найден или вам не принадлежит", show_alert=True)
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,
)
await callback.answer()
enabled = is_idle_enabled(account.bot_name, 730)
await callback.message.edit_text(
"⌚ Выберите игру для накрутки часов",
reply_markup=games_keyboard(bot_name, idle_enabled),
reply_markup=games_keyboard(account.id, enabled),
)
@router.callback_query(
lambda c: c.data and c.data.startswith("farm|"), AdminCallbackFilter()
)
@router.callback_query(lambda c: c.data and c.data.startswith("farm|"))
async def farm_game(callback: CallbackQuery) -> None:
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,
)
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,
)
await callback.answer(text, show_alert=True)
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),
)
+2 -15
View File
@@ -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()
)
+36 -77
View File
@@ -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)
+12 -8
View File
@@ -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(
+12 -10
View File
@@ -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),
)
+21
View File
@@ -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"👤 <b>Профиль</b>\n\n"
f"🆔: <code>{telegram_id}</code>\n"
f"💰 Баланс: <code>{balance}</code> ₽\n"
f"🤖 Ботов: <code>{bot_count}</code>",
reply_markup=back_keyboard(),
)
+13 -8
View File
@@ -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<code>{bot_name}</code>\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
)
+5 -3
View File
@@ -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:
+36 -13
View File
@@ -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: <code>{conf['id']}</code>\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: <code>{conf['id']}</code>\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)
+190
View File
@@ -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()
+169
View File
@@ -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)
+82
View File
@@ -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,
)
+6 -6
View File
@@ -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
)
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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
+9
View File
@@ -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 = {}
+6 -3
View File
@@ -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,13 +79,15 @@ 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 user_id == ADMIN_ID:
if current_version != latest_version:
lines.append(f"Доступно обновление: {latest_version}")
@@ -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"<b>💻 Панель управления ASF</b>\n\n{format_asf(data)}"
return f"<b>💻 Панель управления ASF</b>\n\n{format_asf(data, user_id)}"
def get_farm_summary(bots: dict) -> str:
+99 -56
View File
@@ -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=[
[InlineKeyboardButton(text="🆙 Обновить", callback_data="refresh")],
def main_keyboard(user_id: int | None = None) -> InlineKeyboardMarkup:
keyboard = [
[InlineKeyboardButton(text="🤖 Боты", callback_data="bots")],
[
InlineKeyboardButton(
text="🔑 Активация ключей",
callback_data="redeem_keys",
)
],
[InlineKeyboardButton(text="📁 Плагины", callback_data="plugins")],
[InlineKeyboardButton(text="💻 Консоль", callback_data="console")],
[
InlineKeyboardButton(
text="♻ Перезапустить ASF",
callback_data="restart_asf_confirm",
)
],
[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")],
)
keyboard.extend(
[
[
InlineKeyboardButton(
text="🤵 Админ-панель", callback_data="admin_panel"
)
],
],
)
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")]
]
)
+6
View File
@@ -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: