refactor: balance handling and upload processing

This commit is contained in:
2026-07-24 16:48:32 +05:00
parent 8bd5c80693
commit 9009147b52
9 changed files with 341 additions and 122 deletions
+4 -1
View File
@@ -19,7 +19,10 @@ class User(Base):
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True) last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
balance_credits: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False) # Имя столбца сохраняется для совместимости с существующей базой данных.
balance_rubles: Mapped[int] = mapped_column(
"balance_credits", BigInteger, default=0, nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
+24 -5
View File
@@ -1,5 +1,5 @@
from aiogram import Router from aiogram import Router
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery, Message
from bot.api.asf import get_all_bots from bot.api.asf import get_all_bots
from bot.logging_utils import get_logger from bot.logging_utils import get_logger
@@ -27,6 +27,28 @@ async def owned_callback_bot(callback: CallbackQuery, prefix: str):
return await get_owned_bot(callback.from_user.id, account_id) return await get_owned_bot(callback.from_user.id, account_id)
async def send_bots_menu(message: Message, user_id: int) -> None:
"""Отправляет отдельное сообщение со списком ботов пользователя."""
response = get_all_bots()
if response.status_code != 200 or not response.json().get("Success"):
await message.answer("Не удалось загрузить список ботов")
return
asf_bots = response.json().get("Result", {})
if user_id == ADMIN_ID:
await ensure_admin_compatibility(list(asf_bots))
accounts = await list_user_bot_accounts(user_id)
rows = [
(account.id, account.bot_name, asf_bots.get(account.bot_name, {}))
for account in accounts
]
await message.answer(
f"<b>🤖 Ваших ботов:</b> {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 == "bot_loading") @router.callback_query(lambda c: c.data == "bot_loading")
async def bot_loading_handler(callback: CallbackQuery) -> None: async def bot_loading_handler(callback: CallbackQuery) -> None:
await callback.answer( await callback.answer(
@@ -37,10 +59,8 @@ async def bot_loading_handler(callback: CallbackQuery) -> None:
@router.callback_query(lambda c: c.data == "bots") @router.callback_query(lambda c: c.data == "bots")
async def bots_handler(callback: CallbackQuery) -> None: async def bots_handler(callback: CallbackQuery) -> None:
await stop_twofa_task(callback.message.message_id) await stop_twofa_task(callback.message.message_id)
response = get_all_bots()
await callback.answer() await callback.answer()
response = get_all_bots()
if response.status_code != 200 or not response.json().get("Success"): if response.status_code != 200 or not response.json().get("Success"):
await callback.message.edit_text("Не удалось загрузить список ботов") await callback.message.edit_text("Не удалось загрузить список ботов")
return return
@@ -54,7 +74,6 @@ async def bots_handler(callback: CallbackQuery) -> None:
(account.id, account.bot_name, asf_bots.get(account.bot_name, {})) (account.id, account.bot_name, asf_bots.get(account.bot_name, {}))
for account in accounts for account in accounts
] ]
await callback.message.edit_text( await callback.message.edit_text(
f"<b>🤖 Ваших ботов:</b> {len(rows)}\n\n{get_farm_summary({name: bot for _, name, bot in rows})}", f"<b>🤖 Ваших ботов:</b> {len(rows)}\n\n{get_farm_summary({name: bot for _, name, bot in rows})}",
reply_markup=bots_keyboard(rows), reply_markup=bots_keyboard(rows),
+13 -3
View File
@@ -11,7 +11,10 @@ from bot.services.redeem import (
redeem_all_bots_keys, redeem_all_bots_keys,
redeem_single_bot_keys, redeem_single_bot_keys,
) )
from bot.services.bot_accounts import list_user_bot_accounts from bot.services.bot_accounts import (
get_owned_bot,
list_user_activation_bot_accounts,
)
from bot.states import ConsoleFlow, RedeemFlow from bot.states import ConsoleFlow, RedeemFlow
@@ -103,12 +106,19 @@ async def handle_single_bot_redeem(message: Message, state: FSMContext):
bot_name = state_data.get("bot_name") bot_name = state_data.get("bot_name")
account_id = state_data.get("account_id") account_id = state_data.get("account_id")
if not isinstance(bot_name, str) or not bot_name: if not isinstance(account_id, int):
await message.answer("Контекст потерян, попробуйте еще раз.") await message.answer("Контекст потерян, попробуйте еще раз.")
await state.clear() await state.clear()
return return
account = await get_owned_bot(message.from_user.id, account_id)
if account is None:
await message.answer("Бот больше недоступен для активации ключей.")
await state.clear()
return
bot_name = account.bot_name
chat_id = message.chat.id chat_id = message.chat.id
menu_message_id = state_data.get("menu_message_id") menu_message_id = state_data.get("menu_message_id")
@@ -257,7 +267,7 @@ async def handle_all_bots_redeem(message: Message, state: FSMContext):
) )
try: try:
accounts = await list_user_bot_accounts(message.from_user.id) accounts = await list_user_activation_bot_accounts(message.from_user.id)
await checking.edit_text( await checking.edit_text(
await redeem_all_bots_keys(keys, [account.bot_name for account in accounts]) await redeem_all_bots_keys(keys, [account.bot_name for account in accounts])
) )
+1 -3
View File
@@ -105,9 +105,7 @@ async def restart_asf(callback: CallbackQuery) -> None:
) )
return return
# ! Меняется подход к перезапуску ASF: вместо того чтобы ждать # Проверяем статус ASF до его запуска, не ожидая фиксированные 6 секунд.
# ! 6 секунд, лучше проверять статус ASF до тех пор он не запустится
# await asyncio.sleep(6)
status = get_asf_status() status = get_asf_status()
await asyncio.sleep(1) await asyncio.sleep(1)
+236 -74
View File
@@ -2,9 +2,8 @@ import asyncio
import hashlib import hashlib
import io import io
import json import json
import posixpath
import re
import zipfile import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import PurePosixPath from pathlib import PurePosixPath
@@ -13,21 +12,26 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from bot.api.asf import get_bot, save_bot_config from bot.api.asf import get_bot, save_bot_config
from bot.config import ADMIN_ID
from bot.handlers.bots import send_bots_menu
from bot.logging_utils import get_logger
from bot.services.balance import (
InsufficientBalance,
increase_balance,
debit_user,
get_bot_slot_price,
get_user_balance,
)
from bot.services.bot_accounts import ( from bot.services.bot_accounts import (
bot_name_exists, bot_name_exists,
create_bot_account, create_bot_account,
user_hash_exists, user_hash_exists,
) )
from bot.services.balance import (
InsufficientBalance,
credit_user,
debit_user,
get_bot_slot_price,
)
from bot.states import UploadFlow from bot.states import UploadFlow
from bot.ui.keyboards import back_keyboard from bot.ui.keyboards import back_keyboard
router = Router() router = Router()
logger = get_logger(__name__)
_groups: dict[str, list[Message]] = {} _groups: dict[str, list[Message]] = {}
_group_tasks: dict[str, asyncio.Task] = {} _group_tasks: dict[str, asyncio.Task] = {}
@@ -36,8 +40,14 @@ _group_tasks: dict[str, asyncio.Task] = {}
async def upload_menu(callback: CallbackQuery, state: FSMContext) -> None: async def upload_menu(callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer() await callback.answer()
await state.set_state(UploadFlow.waiting_upload) await state.set_state(UploadFlow.waiting_upload)
balance = await get_user_balance(callback.from_user.id)
slot_price = await get_bot_slot_price()
await callback.message.edit_text( await callback.message.edit_text(
"Отправьте JSON-конфиг, несколько JSON одним альбомом или ZIP-архив с JSON-файлами.", "Отправьте документы .json или .zip с JSON-конфигурациями.\n\n"
f"💰 Ваш баланс: {balance}\n"
f"💳 Цена одного слота: {slot_price}",
reply_markup=back_keyboard(callback_data="bots"), reply_markup=back_keyboard(callback_data="bots"),
) )
@@ -46,7 +56,9 @@ def _decode_config(raw: bytes) -> dict:
value = json.loads(raw.decode("utf-8-sig")) value = json.loads(raw.decode("utf-8-sig"))
if not isinstance(value, dict): if not isinstance(value, dict):
raise ValueError("JSON должен содержать объект конфигурации") raise ValueError("JSON должен содержать объект конфигурации")
name = value.get("SteamLogin")
if not isinstance(name, str) or not name.strip():
raise ValueError("отсутствует непустой SteamLogin")
return value return value
@@ -58,8 +70,10 @@ def _zip_entries(raw: bytes) -> list[tuple[str, bytes]]:
path = PurePosixPath(name) path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts: if path.is_absolute() or ".." in path.parts:
raise ValueError("ZIP содержит небезопасный путь") raise ValueError("ZIP содержит небезопасный путь")
if item.is_dir() or not name.lower().endswith(".json"): if item.is_dir():
continue continue
if not name.lower().endswith(".json"):
raise ValueError(f"ZIP содержит неподдерживаемый файл: {name}")
entries.append((name, archive.read(item))) entries.append((name, archive.read(item)))
return entries return entries
@@ -70,91 +84,235 @@ async def _download(message: Message) -> bytes:
return buffer.getvalue() return buffer.getvalue()
async def _process(user_id: int, files: list[tuple[str, bytes]]) -> str: @dataclass
results = [] class _Candidate:
seen_hashes: set[str] = set() filename: str
seen_names: set[str] = set() config: dict
digest: str
def _digest(config: dict) -> str:
canonical = json.dumps(
config, ensure_ascii=False, sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
async def _preflight(
user_id: int, files: list[tuple[str, bytes]]
) -> tuple[list[_Candidate], list[str]]:
candidates: list[_Candidate] = []
errors: list[str] = []
parsed: list[tuple[str, dict, str]] = []
for filename, raw in files: for filename, raw in files:
try: try:
config = _decode_config(raw) config = _decode_config(raw)
digest = hashlib.sha256(raw).hexdigest() name = config["SteamLogin"].strip()
name = config.get("SteamLogin") digest = _digest(config)
parsed.append((filename, config, digest))
if name in seen_names or await bot_name_exists(name): except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
raise ValueError("бот с таким именем уже существует") logger.warning(
"Предварительная проверка отклонена: пользователь=%s файл=%s ошибка=%s",
if digest in seen_hashes or await user_hash_exists(user_id, digest): user_id,
raise ValueError("такой конфиг уже загружался") filename,
error,
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})"
) )
errors.append(f"{filename}: {error}")
except Exception as error:
logger.exception(
"Ошибка предварительной проверки: пользователь=%s файл=%s",
user_id,
filename,
)
errors.append(f"{filename}: ошибка проверки ({error})")
name_counts: dict[str, int] = {}
hash_counts: dict[str, int] = {}
for _, config, digest in parsed:
name_counts[config["SteamLogin"].strip()] = (
name_counts.get(config["SteamLogin"].strip(), 0) + 1
)
hash_counts[digest] = hash_counts.get(digest, 0) + 1
for filename, config, digest in parsed:
name = config["SteamLogin"].strip()
try:
if name_counts[name] > 1:
raise ValueError("дублируется имя бота в этой загрузке")
if hash_counts[digest] > 1 or await user_hash_exists(user_id, digest):
raise ValueError(
"такая конфигурация уже загружалась или дублируется в загрузке"
)
if await bot_name_exists(name):
raise ValueError("бот с таким именем уже есть в базе данных")
existing = get_bot(name)
if existing.status_code == 200 and existing.json().get("Result"):
raise ValueError("бот с таким именем уже есть в ASF")
candidates.append(_Candidate(filename, config, digest))
logger.info(
"Предварительная проверка пройдена: пользователь=%s файл=%s бот=%s",
user_id,
filename,
name,
)
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
logger.warning(
"Предварительная проверка отклонена: пользователь=%s файл=%s ошибка=%s",
user_id,
filename,
error,
)
errors.append(f"{filename}: {error}")
except Exception as error:
logger.exception(
"Ошибка предварительной проверки: пользователь=%s файл=%s",
user_id,
filename,
)
errors.append(f"{filename}: ошибка проверки ({error})")
return candidates, errors
async def _process(user_id: int, files: list[tuple[str, bytes]]) -> str:
candidates, results = await _preflight(user_id, files)
if not candidates:
return "\n".join(results) or "❌ Нет конфигураций для загрузки"
is_admin = user_id == ADMIN_ID
slot_price = await get_bot_slot_price()
total = slot_price * len(candidates)
reserved = False
if not is_admin:
try:
await debit_user(user_id, total)
reserved = True
logger.info(
"Средства зарезервированы: пользователь=%s сумма=%s ₽ количество=%s",
user_id,
total,
len(candidates),
)
except InsufficientBalance:
balance = await get_user_balance(user_id)
logger.warning(
"Недостаточно средств для загрузки: пользователь=%s баланс=%s ₽ требуется=%s",
user_id,
balance,
total,
)
results.append(
f"❌ Недостаточно средств: нужно {total} ₽ для {len(candidates)} конф. "
f"(доступно {balance} ₽)\n"
)
results.extend(
f"{candidate.filename}: загрузка не начата"
for candidate in candidates
)
return "\n".join(results)
for candidate in candidates:
name = candidate.config["SteamLogin"].strip()
try:
response = save_bot_config(name, candidate.config)
logger.info(
"Ответ ASF на загрузку: пользователь=%s бот=%s статус=%s",
user_id,
name,
response.status_code,
)
payload = response.json()
if response.status_code != 200 or not payload.get("Success"):
detail = (
payload.get("Message")
or payload.get("Result")
or f"HTTP {response.status_code}"
)
raise ValueError(f"ASF отклонил конфигурацию: {detail}")
await create_bot_account( await create_bot_account(
user_id, user_id,
bot_name=name, bot_name=name,
steam_id=( steam_id=(
str(config.get("SteamID")) if config.get("SteamID") else None str(candidate.config.get("SteamID"))
if candidate.config.get("SteamID")
else None
), ),
source_filename=filename[:255], source_filename=candidate.filename[:255],
config_sha256=digest, config_sha256=candidate.digest,
upload_state="attached", upload_state="attached",
is_attached=True, is_attached=True,
attached_at=datetime.now(timezone.utc), attached_at=datetime.now(timezone.utc),
) )
logger.info(
"Регистрация в базе завершена: пользователь=%s бот=%s", user_id, name
)
results.append(
f"{candidate.filename}: ASF принял, аккаунт зарегистрирован ({name})"
)
except Exception: except Exception as error:
await credit_user(user_id, slot_price) logger.exception(
raise "Ошибка загрузки: пользователь=%s файл=%s бот=%s",
user_id,
candidate.filename,
name,
)
if reserved:
await increase_balance(user_id, slot_price)
logger.info(
"Средства возвращены: пользователь=%s сумма=%s ₽ файл=%s",
user_id,
slot_price,
candidate.filename,
)
results.append(f"{candidate.filename}: {error}")
seen_names.add(name) return "\n".join(results)
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: async def _finish_group(key: str, state: FSMContext) -> None:
await asyncio.sleep(0.8) await asyncio.sleep(0.8)
messages = _groups.pop(key, []) messages = _groups.pop(key, [])
_group_tasks.pop(key, None) _group_tasks.pop(key, None)
if not messages: if not messages:
return return
files = [] files = []
for message in messages: for message in messages:
filename = message.document.file_name or "config.json"
try: try:
files.append( files.append((filename, await _download(message)))
(message.document.file_name or "config.json", await _download(message))
)
except Exception: except Exception:
files.append((message.document.file_name or "config.json", b"")) files.append((filename, b""))
await messages[0].answer(await _process(messages[0].from_user.id, files)) await _send_result_and_menu(messages[0], messages[0].from_user.id, files)
await state.clear() await state.clear()
async def _send_result_and_menu(
message: Message, user_id: int, files: list[tuple[str, bytes]]
) -> None:
result = await _process(user_id, files)
await message.answer(result)
if "" in result:
await send_bots_menu(message, user_id)
@router.message(UploadFlow.waiting_upload, lambda message: bool(message.document)) @router.message(UploadFlow.waiting_upload, lambda message: bool(message.document))
async def upload_document(message: Message, state: FSMContext) -> None: async def upload_document(message: Message, state: FSMContext) -> None:
document = message.document document = message.document
@@ -167,24 +325,28 @@ async def upload_document(message: Message, state: FSMContext) -> None:
_group_tasks[key] = asyncio.create_task(_finish_group(key, state)) _group_tasks[key] = asyncio.create_task(_finish_group(key, state))
return return
try:
raw = await _download(message) raw = await _download(message)
if filename.lower().endswith(".zip"): if filename.lower().endswith(".zip"):
try:
files = _zip_entries(raw) files = _zip_entries(raw)
except Exception as error:
await message.answer(f"{filename}: {error}")
await state.clear()
return
if not files: if not files:
await message.answer("❌ В архиве нет JSON-файлов") await message.answer("❌ В архиве нет JSON-файлов")
else: else:
await message.answer(await _process(message.from_user.id, files)) await _send_result_and_menu(message, message.from_user.id, files)
elif filename.lower().endswith(".json"): elif filename.lower().endswith(".json"):
await message.answer(await _process(message.from_user.id, [(filename, raw)])) await _send_result_and_menu(
message, message.from_user.id, [(filename, raw)]
)
else: else:
await message.answer("❌ Поддерживаются только .json и .zip") await message.answer("❌ Поддерживаются только документы .json и .zip")
except (zipfile.BadZipFile, ValueError) as error:
await message.answer(f"{filename}: {error}")
finally:
await state.clear() await state.clear()
@router.message(UploadFlow.waiting_upload)
async def upload_non_document(message: Message, state: FSMContext) -> None:
await message.answer(
"❌ Отправьте конфигурацию именно документом .json или архивом .zip"
)
+16 -16
View File
@@ -18,14 +18,14 @@ class InsufficientBalance(BalanceError):
def _amount(value: int) -> int: def _amount(value: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0: if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise BalanceError("Amount must be a positive integer") raise BalanceError("Сумма должна быть положительным целым числом")
return value return value
async def _get_or_create_user(session, telegram_id: int) -> User: async def _get_or_create_user(session, telegram_id: int) -> User:
user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
if user is None: if user is None:
user = User(telegram_id=telegram_id, balance_credits=0) user = User(telegram_id=telegram_id, balance_rubles=0)
session.add(user) session.add(user)
await session.flush() await session.flush()
return user return user
@@ -34,7 +34,7 @@ async def _get_or_create_user(session, telegram_id: int) -> User:
async def get_user_balance(telegram_id: int) -> int: async def get_user_balance(telegram_id: int) -> int:
async with async_session() as session: async with async_session() as session:
user = await session.scalar(select(User).where(User.telegram_id == telegram_id)) user = await session.scalar(select(User).where(User.telegram_id == telegram_id))
return int(user.balance_credits) if user else 0 return int(user.balance_rubles) if user else 0
async def change_user_balance( async def change_user_balance(
@@ -46,26 +46,26 @@ async def change_user_balance(
and telegram_id != actor_telegram_id and telegram_id != actor_telegram_id
and actor_telegram_id != ADMIN_ID and actor_telegram_id != ADMIN_ID
): ):
raise PermissionError("Only an administrator can adjust another user") raise PermissionError("Только администратор может изменять баланс другого пользователя")
async with async_session() as session: async with async_session() as session:
user = await _get_or_create_user(session, telegram_id) user = await _get_or_create_user(session, telegram_id)
await session.execute( await session.execute(
update(User).where(User.id == user.id).values(balance_credits=amount) update(User).where(User.id == user.id).values(balance_rubles=amount)
) )
await session.commit() await session.commit()
return int( return int(
( (
await session.scalar( await session.scalar(
select(User.balance_credits).where(User.id == user.id) select(User.balance_rubles).where(User.id == user.id)
) )
) )
or 0 or 0
) )
async def credit_user( async def increase_balance(
telegram_id: int, amount: int, *, actor_telegram_id: int | None = None telegram_id: int, amount: int, *, actor_telegram_id: int | None = None
) -> int: ) -> int:
amount = _amount(amount) amount = _amount(amount)
@@ -74,20 +74,20 @@ async def credit_user(
and telegram_id != actor_telegram_id and telegram_id != actor_telegram_id
and actor_telegram_id != ADMIN_ID and actor_telegram_id != ADMIN_ID
): ):
raise PermissionError("Only an administrator can adjust another user") raise PermissionError("Только администратор может изменять баланс другого пользователя")
async with async_session() as session: async with async_session() as session:
user = await _get_or_create_user(session, telegram_id) user = await _get_or_create_user(session, telegram_id)
await session.execute( await session.execute(
update(User) update(User)
.where(User.id == user.id) .where(User.id == user.id)
.values(balance_credits=User.balance_credits + amount) .values(balance_rubles=User.balance_rubles + amount)
) )
await session.commit() await session.commit()
return int( return int(
( (
await session.scalar( await session.scalar(
select(User.balance_credits).where(User.id == user.id) select(User.balance_rubles).where(User.id == user.id)
) )
) )
or 0 or 0
@@ -103,23 +103,23 @@ async def debit_user(
and telegram_id != actor_telegram_id and telegram_id != actor_telegram_id
and actor_telegram_id != ADMIN_ID and actor_telegram_id != ADMIN_ID
): ):
raise PermissionError("Only an administrator can adjust another user") raise PermissionError("Только администратор может изменять баланс другого пользователя")
async with async_session() as session: async with async_session() as session:
user = await _get_or_create_user(session, telegram_id) user = await _get_or_create_user(session, telegram_id)
result = await session.execute( result = await session.execute(
update(User) update(User)
.where(User.id == user.id, User.balance_credits >= amount) .where(User.id == user.id, User.balance_rubles >= amount)
.values(balance_credits=User.balance_credits - amount) .values(balance_rubles=User.balance_rubles - amount)
) )
if result.rowcount != 1: if result.rowcount != 1:
await session.rollback() await session.rollback()
raise InsufficientBalance("Insufficient balance") raise InsufficientBalance("Недостаточно рублей на балансе")
await session.commit() await session.commit()
return int( return int(
( (
await session.scalar( await session.scalar(
select(User.balance_credits).where(User.id == user.id) select(User.balance_rubles).where(User.id == user.id)
) )
) )
or 0 or 0
@@ -166,4 +166,4 @@ async def get_user_profile(telegram_id: int) -> tuple[int, int, int]:
count = await session.scalar( count = await session.scalar(
select(func.count(BotAccount.id)).where(BotAccount.owner_id == user.id) select(func.count(BotAccount.id)).where(BotAccount.owner_id == user.id)
) )
return telegram_id, int(user.balance_credits), int(count or 0) return telegram_id, int(user.balance_rubles), int(count or 0)
+20 -1
View File
@@ -17,6 +17,25 @@ async def list_user_bot_accounts(telegram_id: int) -> list[BotAccount]:
return list(result.scalars()) return list(result.scalars())
async def list_user_activation_bot_accounts(telegram_id: int) -> list[BotAccount]:
"""Возвращает личных ботов, доступных для активации ключей пользователем.
Боты, импортированные из ASF для совместимости с панелью администратора,
являются системными, а не загруженными администратором лично.
"""
async with async_session() as session:
result = await session.execute(
select(BotAccount)
.join(User)
.where(
User.telegram_id == telegram_id,
BotAccount.source_filename != "existing-asf",
)
.order_by(BotAccount.bot_name)
)
return list(result.scalars())
async def get_owned_bot(telegram_id: int, account_id: int) -> BotAccount | None: async def get_owned_bot(telegram_id: int, account_id: int) -> BotAccount | None:
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@@ -67,7 +86,7 @@ async def user_hash_exists(telegram_id: int, config_sha256: str) -> bool:
async def ensure_admin_compatibility(bot_names: list[str]) -> None: async def ensure_admin_compatibility(bot_names: list[str]) -> None:
"""Keep ASF bots created before ownership was introduced visible to the admin.""" """Показывает администратору ботов ASF, созданных до добавления владельцев."""
for bot_name in bot_names: for bot_name in bot_names:
if await bot_name_exists(bot_name): if await bot_name_exists(bot_name):
continue continue
+17 -3
View File
@@ -111,12 +111,26 @@ async def redeem_single_bot_keys(bot_name: str, keys: list[str]) -> str:
return text[:4000] + ("\n\n...обрезано" if len(text) > 4000 else "") return text[:4000] + ("\n\n...обрезано" if len(text) > 4000 else "")
async def redeem_all_bots_keys(keys: list[str], allowed_bots: list[str] | None = None) -> str: async def redeem_all_bots_keys(keys: list[str], allowed_bots: list[str]) -> str:
logger.info("Начата активация ключей по всем ботам: count=%s", len(keys)) """Активирует ключи только на явно указанных личных ботах."""
if not allowed_bots:
logger.info("Нет личных ботов для массовой активации ключей")
return (
"Результат активации\n\n"
"⚠️ У вас нет ботов, доступных для массовой активации.\n"
"Ключи не отправлялись в ASF."
)
logger.info(
"Начата активация ключей на разрешенных ботах: keys=%s bots=%s",
len(keys),
len(allowed_bots),
)
response = get_all_bots() response = get_all_bots()
data = response.json() data = response.json()
available = 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] allowed = set(allowed_bots)
bots = [name for name in available if name in allowed]
logger.info("Для массовой активации найдено ботов: count=%s", len(bots)) logger.info("Для массовой активации найдено ботов: count=%s", len(bots))
success_count = 0 success_count = 0
failed_count = 0 failed_count = 0
+2 -8
View File
@@ -6,12 +6,11 @@ from bot.config import ADMIN_ID
def main_keyboard(user_id: int | None = None) -> InlineKeyboardMarkup: def main_keyboard(user_id: int | None = None) -> InlineKeyboardMarkup:
keyboard = [ keyboard = [
[InlineKeyboardButton(text="👤 Профиль", callback_data="profile")],
[InlineKeyboardButton(text="🤖 Боты", callback_data="bots")], [InlineKeyboardButton(text="🤖 Боты", callback_data="bots")],
[InlineKeyboardButton(text="🔑 Активация ключей", callback_data="redeem_keys")], [InlineKeyboardButton(text="🔑 Активация ключей", callback_data="redeem_keys")],
] ]
profile_index = -1 if user_id == ADMIN_ID else 0
if user_id == ADMIN_ID: if user_id == ADMIN_ID:
keyboard.insert( keyboard.insert(
0, 0,
@@ -28,11 +27,6 @@ def main_keyboard(user_id: int | None = None) -> InlineKeyboardMarkup:
], ],
) )
keyboard.insert(
profile_index,
[InlineKeyboardButton(text="👤 Профиль", callback_data="profile")],
)
return InlineKeyboardMarkup(inline_keyboard=keyboard) return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -102,7 +96,7 @@ def bots_keyboard(bots: list[tuple[int, str, dict]]) -> InlineKeyboardMarkup:
keyboard.extend( keyboard.extend(
[ [
[InlineKeyboardButton(text="⬆️ Загрузить конфиг", callback_data="upload")], [InlineKeyboardButton(text="⬆️ Загрузить конфиг", callback_data="upload")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_panel")], [InlineKeyboardButton(text="🔙 Назад", callback_data="back")],
] ]
) )
return InlineKeyboardMarkup(inline_keyboard=keyboard) return InlineKeyboardMarkup(inline_keyboard=keyboard)