forked from FOSS/ASF-Control-Bot
refactor: balance handling and upload processing
This commit is contained in:
+4
-1
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+244
-82
@@ -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",
|
||||||
|
user_id,
|
||||||
|
filename,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
errors.append(f"❌ {filename}: {error}")
|
||||||
|
|
||||||
if digest in seen_hashes or await user_hash_exists(user_id, digest):
|
except Exception as error:
|
||||||
raise ValueError("такой конфиг уже загружался")
|
logger.exception(
|
||||||
|
"Ошибка предварительной проверки: пользователь=%s файл=%s",
|
||||||
|
user_id,
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
errors.append(f"❌ {filename}: ошибка проверки ({error})")
|
||||||
|
|
||||||
existing = get_bot(name)
|
name_counts: dict[str, int] = {}
|
||||||
if existing.status_code == 200 and existing.json().get("Result"):
|
hash_counts: dict[str, int] = {}
|
||||||
raise ValueError("бот с таким именем уже существует в ASF")
|
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
|
||||||
|
|
||||||
slot_price = await get_bot_slot_price()
|
for filename, config, digest in parsed:
|
||||||
await debit_user(user_id, slot_price)
|
name = config["SteamLogin"].strip()
|
||||||
try:
|
try:
|
||||||
response = save_bot_config(name, config)
|
if name_counts[name] > 1:
|
||||||
if response.status_code != 200 or not response.json().get("Success"):
|
raise ValueError("дублируется имя бота в этой загрузке")
|
||||||
raise ValueError(
|
|
||||||
f"ASF отклонил конфиг (HTTP {response.status_code})"
|
|
||||||
)
|
|
||||||
|
|
||||||
await create_bot_account(
|
if hash_counts[digest] > 1 or await user_hash_exists(user_id, digest):
|
||||||
user_id,
|
raise ValueError(
|
||||||
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:
|
if await bot_name_exists(name):
|
||||||
await credit_user(user_id, slot_price)
|
raise ValueError("бот с таким именем уже есть в базе данных")
|
||||||
raise
|
existing = get_bot(name)
|
||||||
|
|
||||||
seen_names.add(name)
|
if existing.status_code == 200 and existing.json().get("Result"):
|
||||||
seen_hashes.add(digest)
|
raise ValueError("бот с таким именем уже есть в ASF")
|
||||||
results.append(f"✅ {filename}")
|
|
||||||
|
|
||||||
except (
|
candidates.append(_Candidate(filename, config, digest))
|
||||||
InsufficientBalance,
|
logger.info(
|
||||||
ValueError,
|
"Предварительная проверка пройдена: пользователь=%s файл=%s бот=%s",
|
||||||
json.JSONDecodeError,
|
user_id,
|
||||||
UnicodeDecodeError,
|
filename,
|
||||||
zipfile.BadZipFile,
|
name,
|
||||||
) as error:
|
)
|
||||||
results.append(f"❌ {filename}: {error}")
|
|
||||||
|
|
||||||
except Exception:
|
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||||
results.append(f"❌ {filename}: внутренняя ошибка")
|
logger.warning(
|
||||||
|
"Предварительная проверка отклонена: пользователь=%s файл=%s ошибка=%s",
|
||||||
|
user_id,
|
||||||
|
filename,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
errors.append(f"❌ {filename}: {error}")
|
||||||
|
|
||||||
return f"Обработано файлов: {len(files)}"
|
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(
|
||||||
|
user_id,
|
||||||
|
bot_name=name,
|
||||||
|
steam_id=(
|
||||||
|
str(candidate.config.get("SteamID"))
|
||||||
|
if candidate.config.get("SteamID")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
source_filename=candidate.filename[:255],
|
||||||
|
config_sha256=candidate.digest,
|
||||||
|
upload_state="attached",
|
||||||
|
is_attached=True,
|
||||||
|
attached_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Регистрация в базе завершена: пользователь=%s бот=%s", user_id, name
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
f"✅ {candidate.filename}: ASF принял, аккаунт зарегистрирован ({name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(
|
||||||
|
"Ошибка загрузки: пользователь=%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}")
|
||||||
|
|
||||||
|
return "\n".join(results)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
raw = await _download(message)
|
try:
|
||||||
if filename.lower().endswith(".zip"):
|
raw = await _download(message)
|
||||||
try:
|
if filename.lower().endswith(".zip"):
|
||||||
files = _zip_entries(raw)
|
files = _zip_entries(raw)
|
||||||
|
if not files:
|
||||||
except Exception as error:
|
await message.answer("❌ В архиве нет JSON-файлов")
|
||||||
await message.answer(f"❌ {filename}: {error}")
|
else:
|
||||||
await state.clear()
|
await _send_result_and_menu(message, message.from_user.id, files)
|
||||||
return
|
elif filename.lower().endswith(".json"):
|
||||||
|
await _send_result_and_menu(
|
||||||
if not files:
|
message, message.from_user.id, [(filename, raw)]
|
||||||
await message.answer("❌ В архиве нет JSON-файлов")
|
)
|
||||||
else:
|
else:
|
||||||
await message.answer(await _process(message.from_user.id, files))
|
await message.answer("❌ Поддерживаются только документы .json и .zip")
|
||||||
|
except (zipfile.BadZipFile, ValueError) as error:
|
||||||
|
await message.answer(f"❌ {filename}: {error}")
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
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()
|
@router.message(UploadFlow.waiting_upload)
|
||||||
|
async def upload_non_document(message: Message, state: FSMContext) -> None:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Отправьте конфигурацию именно документом .json или архивом .zip"
|
||||||
|
)
|
||||||
|
|||||||
+16
-16
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user