add balance

This commit is contained in:
Yuriy Yuriev
2026-05-14 21:04:28 +07:00
parent bdf3fef7ec
commit fe9ada0558
7 changed files with 734 additions and 170 deletions
+6 -6
View File
@@ -26,12 +26,12 @@ class Settings(BaseSettings):
VIEWPORT_HEIGHT: int = 768
# Default values
DEFAULT_MIN_DELAY: int = 10
DEFAULT_MAX_DELAY: int = 30
DEFAULT_MIN_READING: int = 10
DEFAULT_MAX_READING: int = 30
DEFAULT_MONITOR_MINUTES: int = 10
DEFAULT_VISITS_PER_LINK: int = 5
DEFAULT_MIN_DELAY: int = 30
DEFAULT_MAX_DELAY: int = 60
DEFAULT_MIN_READING: int = 45
DEFAULT_MAX_READING: int = 90
DEFAULT_VISITS_PER_LINK: int = 2
DEFAULT_MONITOR_MINUTES: int = 60
MTPROTO_SECRET: str = "00000000000000000000000000000000"
# Timing
MAX_REDIRECT_WAIT: int = 30
+613 -124
View File
@@ -26,7 +26,7 @@ from services.browser_service import BrowserService
from services.visit_service import VisitScheduler
from utils.helpers import parse_range, format_range, extract_domain
from utils.telegram import send_message_safe, send_visit_result
from managers.storage import TaskStorage, ChatStorage
from managers.storage import TaskStorage, ChatStorage, BalanceStorage
from auth.manager import AuthManager
from auth.storage import AuthStorage
from auth import ADMIN_PASSWORD
@@ -54,7 +54,12 @@ class BotInterface:
self.chat_storage = chat_storage
self.auth_manager = AuthManager()
self.auth_storage = AuthStorage()
self.balance_storage = BalanceStorage()
self._user_input_state: Dict[int, dict] = {}
self._user_task_state: Dict[int, dict] = {} # шаги создания задачи пользователем
self._admin_balance_state: Dict[int, int] = {} # admin_id -> target_user_id
self._menu_msg: Dict[int, int] = {} # user_id -> inline keyboard message_id
self._menu_top_msg: Dict[int, int] = {} # user_id -> reply keyboard message_id
self._bot_ref = bot_ref
self._waiting_password: Dict[int, str] = {} # user_id -> "register" or "login"
@@ -73,116 +78,90 @@ class BotInterface:
async def cmd_start(message: Message):
"""Обработка /start."""
user_id = message.from_user.id
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
if interface.auth_manager.is_authenticated(user_id) and interface.auth_manager.is_admin(user_id):
await interface._show_main_menu(message)
return
if interface.auth_manager.is_authenticated(user_id):
await message.answer(
"👋 Привет!\n\n"
"У вас нет доступа к функционалу бота.\n"
"Обратитесь к администратору.",
reply_markup=ReplyKeyboardRemove()
)
await interface._show_user_menu(message)
return
exists = await interface.auth_storage.user_exists(user_id)
if exists:
await message.answer("👋 С возвращением!\n\nВведите пароль для входа:")
sent = await message.answer("👋 С возвращением!\n\nВведите пароль для входа:")
interface._waiting_password[user_id] = "login"
else:
await message.answer(
sent = await message.answer(
"👋 Добро пожаловать!\n\n"
"Вы здесь впервые. Придумайте пароль для регистрации\n"
"_(минимум 4 символа):_"
)
interface._waiting_password[user_id] = "register"
interface._menu_msg[user_id] = sent.message_id
@dp.message(Command("register"))
async def cmd_register(message: Message):
"""Регистрация: /register <пароль>"""
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
parts = message.text.split(maxsplit=1)
if len(parts) < 2 or len(parts[1]) < 4:
await message.answer(
"❌ Использование: /register <пароль от 4 символов>"
)
await interface._send_temp(message, "❌ Использование: /register <пароль от 4 символов>")
return
password = parts[1]
user_id = message.from_user.id
exists = await interface.auth_storage.user_exists(user_id)
if exists:
await message.answer(
"⚠️ Вы уже зарегистрированы. Используйте /login <пароль>"
)
await interface._send_temp(message, "⚠️ Вы уже зарегистрированы. Используйте /login <пароль>")
return
password_hash, salt = interface.auth_manager._hash_password(password)
await interface.auth_storage.register(user_id, password_hash, salt)
is_admin = (password == ADMIN_PASSWORD)
interface.auth_manager.login(user_id, is_admin=is_admin)
if is_admin:
await message.answer(
"✅ Регистрация прошла успешно!\n"
"🔓 Вы вошли как **АДМИНИСТРАТОР**.\n"
"Весь функционал бота доступен."
)
await interface._update_commands()
await interface._show_main_menu(message)
else:
await message.answer(
"✅ Регистрация прошла успешно!\n"
"👤 Вы вошли как **ПОЛЬЗОВАТЕЛЬ**.\n"
"Функционал ограничен."
)
await interface._show_user_menu(message)
@dp.message(Command("login"))
async def cmd_login(message: Message):
"""Вход: /login <пароль>"""
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
await message.answer("❌ Использование: /login <пароль>")
await interface._send_temp(message, "❌ Использование: /login <пароль>")
return
password = parts[1]
user_id = message.from_user.id
user_data = await interface.auth_storage.get_user(user_id)
if not user_data:
await message.answer(
"❌ Вы не зарегистрированы.\n"
"Используйте /register <пароль>"
)
await interface._send_temp(message, "❌ Вы не зарегистрированы.\nИспользуйте /register <пароль>")
return
locked = interface.auth_manager.is_locked_out(user_id)
if locked:
await message.answer("🔒 Слишком много попыток. Попробуйте позже.")
if interface.auth_manager.is_locked_out(user_id):
await interface._send_temp(message, "🔒 Слишком много попыток. Попробуйте позже.")
return
if interface.auth_manager.verify_password(
if password == ADMIN_PASSWORD:
interface.auth_manager.login(user_id, is_admin=True)
await interface._update_commands()
await interface._show_main_menu(message)
elif interface.auth_manager.verify_password(
password, user_data["password_hash"], user_data["salt"]
):
is_admin = (password == ADMIN_PASSWORD)
interface.auth_manager.login(user_id, is_admin=is_admin)
if is_admin:
await message.answer(
"✅ Вы вошли в систему!\n"
"🔓 Вы вошли как **АДМИНИСТРАТОР**."
)
await interface._update_commands()
else:
await message.answer(
"✅ Вы вошли в систему!\n"
"👤 Вы вошли как **ПОЛЬЗОВАТЕЛЬ**."
)
interface.auth_manager.login(user_id, is_admin=False)
await interface._show_user_menu(message)
else:
interface.auth_manager.record_failed_attempt(user_id)
await message.answer("❌ Неверный пароль")
await interface._send_temp(message, "❌ Неверный пароль")
@dp.message(Command("logout"))
async def cmd_logout(message: Message):
"""Выйти из аккаунта."""
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
interface.auth_manager.logout(message.from_user.id)
await message.answer(
"🔑 Вы вышли из системы.",
reply_markup=ReplyKeyboardRemove()
)
await interface._update_commands()
await interface._send_temp(message, "🔑 Вы вышли из системы.", delay=3)
# Декоратор: доступ только для авторизованных
async def require_auth(event):
@@ -219,9 +198,16 @@ class BotInterface:
async def cb_main_menu(callback: CallbackQuery):
if not await require_admin(callback):
return
await interface._show_main_menu(callback.message)
await interface._show_main_menu(callback.message, user_id=callback.from_user.id)
await callback.answer()
# === Пользователи (admin reply-button) ===
@dp.message(F.text == "👥 Пользователи")
async def btn_users(message: Message):
if not await require_admin(message):
return
await interface._show_users_list(message)
# === Стримеры ===
@dp.message(F.text == "📺 Стримеры")
@dp.message(Command("streamers"))
@@ -394,7 +380,6 @@ class BotInterface:
async def cb_task_set(callback: CallbackQuery):
if not await require_admin(callback):
return
"""Установка параметра задачи."""
data = callback.data
logger.info(f"SET callback: {data}")
@@ -432,7 +417,8 @@ class BotInterface:
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data=f"tdetail_{task_id}"))
prompt = prompts.get(param, f"Введите значение для {param}:")
await callback.message.answer(prompt, reply_markup=builder.as_markup())
sent = await callback.message.answer(prompt, reply_markup=builder.as_markup())
interface._user_input_state[callback.from_user.id]["prompt_msg_id"] = sent.message_id
await callback.answer(f"Ожидаю ввод: {param}")
@dp.callback_query(F.data.startswith("tdelete_"))
@@ -449,7 +435,6 @@ class BotInterface:
async def treset(callback: CallbackQuery):
if not await require_admin(callback):
return
"""Сброс статистики активной задачи."""
tid = callback.data.replace("treset_", "", 1)
await interface.task_manager.update_task(
tid,
@@ -461,6 +446,194 @@ class BotInterface:
await callback.answer("🔄 Статистика сброшена")
await interface._show_task_detail(callback, tid)
# === Кабинет пользователя ===
@dp.callback_query(F.data == "user_menu")
async def cb_user_menu(callback: CallbackQuery):
await interface._show_user_menu(callback.message, edit=True)
await callback.answer()
@dp.callback_query(F.data == "my_balance")
async def cb_my_balance(callback: CallbackQuery):
uid = callback.from_user.id
balance = await interface.balance_storage.get_balance(uid)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_menu"))
await callback.message.edit_text(
f"💰 **Ваш баланс**\n\n**{balance}** переходов",
reply_markup=builder.as_markup()
)
await callback.answer()
@dp.callback_query(F.data == "new_task")
async def cb_new_task(callback: CallbackQuery):
uid = callback.from_user.id
balance = await interface.balance_storage.get_balance(uid)
if balance <= 0:
await callback.answer("❌ Недостаточно баланса", show_alert=True)
return
all_tasks = await interface.task_manager.get_all_tasks()
active_count = sum(
1 for p in all_tasks.values()
if p.user_id == uid and not p.completed and not p.stopped
)
if active_count >= 3:
await callback.answer("❌ Максимум 3 активные задачи", show_alert=True)
return
interface._user_task_state[uid] = {"step": "channel"}
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_new_task"))
await callback.message.edit_text(
f"📝 **НОВАЯ ЗАДАЧА**\n\n"
f"💰 Баланс: {balance} переходов\n\n"
"Шаг 1/2: Введите название вашего Twitch канала:",
reply_markup=builder.as_markup()
)
interface._menu_msg[uid] = callback.message.message_id
await callback.answer()
@dp.callback_query(F.data == "enter_admin_mode")
async def cb_enter_admin_mode(callback: CallbackQuery):
uid = callback.from_user.id
if not interface.auth_manager.is_authenticated(uid):
await callback.answer("❌ Требуется авторизация", show_alert=True)
return
interface._user_task_state.pop(uid, None)
interface._waiting_password[uid] = "admin_upgrade"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="user_menu"))
await callback.message.edit_text(
"🔑 Введите пароль администратора:",
reply_markup=builder.as_markup()
)
await callback.answer()
@dp.callback_query(F.data == "switch_to_user")
async def cb_switch_to_user(callback: CallbackQuery):
uid = callback.from_user.id
if not interface.auth_manager.is_authenticated(uid):
await callback.answer("❌ Требуется авторизация", show_alert=True)
return
interface.auth_manager.login(uid, is_admin=False)
await interface._show_user_menu(callback.message)
await callback.answer("👤 Переключились в режим пользователя")
@dp.callback_query(F.data == "cancel_new_task")
async def cb_cancel_new_task(callback: CallbackQuery):
interface._user_task_state.pop(callback.from_user.id, None)
await interface._show_user_menu(callback.message)
await callback.answer("Отменено")
@dp.callback_query(F.data == "my_tasks")
async def cb_my_tasks(callback: CallbackQuery):
await interface._show_user_tasks(callback.message, callback.from_user.id, edit=True)
await callback.answer()
async def _get_user_task(callback: CallbackQuery, tid: str):
uid = callback.from_user.id
if not interface.auth_manager.is_authenticated(uid):
await callback.answer("❌ Требуется авторизация", show_alert=True)
return None, uid
params = await interface.task_manager.get_task(tid)
if not params or params.user_id != uid:
await callback.answer("❌ Задача не найдена", show_alert=True)
return None, uid
return params, uid
@dp.callback_query(F.data.startswith("ustop_"))
async def cb_user_stop_task(callback: CallbackQuery):
tid = callback.data.replace("ustop_", "", 1)
params, uid = await _get_user_task(callback, tid)
if not params:
return
await interface.background_tasks.cancel_task(tid)
await interface.task_manager.remove_task(tid)
if interface.storage:
await interface.storage.delete_task(tid)
await callback.answer("🗑️ Задача удалена")
await interface._show_user_tasks(callback.message, uid, edit=True)
@dp.callback_query(F.data.startswith("upause_"))
async def cb_user_pause_task(callback: CallbackQuery):
tid = callback.data.replace("upause_", "", 1)
params, uid = await _get_user_task(callback, tid)
if not params:
return
await interface.task_manager.pause_task(tid)
await callback.answer("⏸️ Пауза")
await interface._show_user_tasks(callback.message, uid, edit=True)
@dp.callback_query(F.data.startswith("uresume_"))
async def cb_user_resume_task(callback: CallbackQuery):
tid = callback.data.replace("uresume_", "", 1)
params, uid = await _get_user_task(callback, tid)
if not params:
return
await interface.task_manager.resume_task(tid)
await callback.answer("▶️ Возобновлено")
await interface._show_user_tasks(callback.message, uid, edit=True)
@dp.callback_query(F.data.startswith("uedit_"))
async def cb_user_edit_task(callback: CallbackQuery):
tid = callback.data.replace("uedit_", "", 1)
params, uid = await _get_user_task(callback, tid)
if not params:
return
interface._user_task_state[uid] = {"step": "edit_domains", "task_id": tid}
current = ", ".join(params.allowed_domains) if params.allowed_domains else "все"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="my_tasks"))
await callback.message.edit_text(
f"🌐 **Изменить домены**\n\n"
f"Текущие: `{current}`\n\n"
"Введите новые домены через запятую:",
reply_markup=builder.as_markup()
)
await callback.answer()
# === Панель пользователей (admin) ===
@dp.callback_query(F.data == "menu_users")
async def cb_menu_users(callback: CallbackQuery):
if not await require_admin(callback):
return
await interface._show_users_list(callback.message, edit=True)
await callback.answer()
@dp.callback_query(F.data.startswith("udetail_"))
async def cb_user_detail(callback: CallbackQuery):
if not await require_admin(callback):
return
target_uid = int(callback.data.replace("udetail_", "", 1))
await interface._show_user_detail(callback, target_uid)
await callback.answer()
@dp.callback_query(F.data.startswith("ubal_"))
async def cb_user_balance(callback: CallbackQuery):
if not await require_admin(callback):
return
parts = callback.data.split("_")
# формат: ubal_{amount}_{user_id} или ubal_custom_{user_id}
if parts[1] == "custom":
target_uid = int(parts[2])
interface._admin_balance_state[callback.from_user.id] = target_uid
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data=f"udetail_{target_uid}"))
await callback.message.answer("Введите сумму для пополнения:", reply_markup=builder.as_markup())
await callback.answer()
else:
amount = int(parts[1])
target_uid = int(parts[2])
new_balance = await interface.balance_storage.add_balance(target_uid, amount)
await callback.answer(f"✅ +{amount} переходов. Баланс: {new_balance}")
await interface._show_user_detail(callback, target_uid)
@dp.callback_query(F.data.startswith("utasks_"))
async def cb_user_tasks_admin(callback: CallbackQuery):
if not await require_admin(callback):
return
target_uid = int(callback.data.replace("utasks_", "", 1))
await interface._show_user_tasks(callback.message, target_uid, edit=True, for_admin=True)
await callback.answer()
# Единый обработчик всех текстовых сообщений — регистрируется последним,
# чтобы не перекрывать обработчики кнопок с фильтрами F.text
@dp.message()
@@ -472,11 +645,14 @@ class BotInterface:
if user_id in interface._waiting_password:
if not text:
return
# Пароль удаляем немедленно
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
mode = interface._waiting_password.pop(user_id)
if mode == "register":
if len(text) < 4:
await message.answer("❌ Пароль слишком короткий (минимум 4 символа). Попробуйте ещё раз:")
sent = await message.answer("❌ Пароль слишком короткий (минимум 4 символа). Попробуйте ещё раз:")
interface._menu_msg[user_id] = sent.message_id
interface._waiting_password[user_id] = "register"
return
password_hash, salt = interface.auth_manager._hash_password(text)
@@ -487,57 +663,70 @@ class BotInterface:
await interface._update_commands()
await interface._show_main_menu(message)
else:
await message.answer(
"✅ Регистрация прошла успешно!\n"
"👤 Вы вошли как **ПОЛЬЗОВАТЕЛЬ**.\n\n"
"Обратитесь к администратору для получения доступа."
)
await interface._show_user_menu(message)
elif mode == "login":
user_data = await interface.auth_storage.get_user(user_id)
if not user_data:
await message.answer(
"❌ Вы не зарегистрированы.\n"
"Напишите администратору для получения доступа."
)
await interface._send_temp(message, "❌ Вы не зарегистрированы.\nНапишите администратору.")
return
if interface.auth_manager.is_locked_out(user_id):
await message.answer("🔒 Слишком много попыток. Попробуйте позже.")
await interface._send_temp(message, "🔒 Слишком много попыток. Попробуйте позже.")
return
if interface.auth_manager.verify_password(
if text == ADMIN_PASSWORD:
interface.auth_manager.login(user_id, is_admin=True)
await interface._update_commands()
await interface._show_main_menu(message)
elif interface.auth_manager.verify_password(
text, user_data["password_hash"], user_data["salt"]
):
is_admin = (text == ADMIN_PASSWORD)
interface.auth_manager.login(user_id, is_admin=is_admin)
if is_admin:
interface.auth_manager.login(user_id, is_admin=False)
await interface._show_user_menu(message)
else:
interface.auth_manager.record_failed_attempt(user_id)
sent = await message.answer("❌ Неверный пароль. Попробуйте ещё раз:")
interface._menu_msg[user_id] = sent.message_id
interface._waiting_password[user_id] = "login"
elif mode == "admin_upgrade":
if text == ADMIN_PASSWORD:
interface.auth_manager.login(user_id, is_admin=True)
await interface._update_commands()
await interface._show_main_menu(message)
else:
await message.answer(
"👋 Привет!\n\n"
"У вас нет доступа к функционалу бота.\n"
"Обратитесь к администратору.",
reply_markup=ReplyKeyboardRemove()
)
else:
interface.auth_manager.record_failed_attempt(user_id)
await message.answer("❌ Неверный пароль. Попробуйте ещё раз:")
interface._waiting_password[user_id] = "login"
sent = await message.answer("❌ Неверный пароль администратора. Попробуйте ещё раз:")
interface._menu_msg[user_id] = sent.message_id
interface._waiting_password[user_id] = "admin_upgrade"
return
# Ввод шагов создания задачи (пользователь)
if user_id in interface._user_task_state:
await interface._process_user_task_input(message)
return
if not interface.auth_manager.is_authenticated(user_id):
await message.answer(
"🔒 Требуется авторизация!\n\n"
"/register <пароль> — зарегистрироваться\n"
"/login <пароль> — войти"
await interface._send_temp(
message,
"🔒 Требуется авторизация!\n\n/register <пароль> — зарегистрироваться\n/login <пароль> — войти"
)
return
if not interface.auth_manager.is_admin(user_id):
await message.answer(
"⛔ **Нет доступа!**\n\n"
"Эта функция доступна только администраторам."
)
await interface._show_user_menu(message)
return
# Ввод суммы баланса (admin)
if user_id in interface._admin_balance_state:
target_uid = interface._admin_balance_state.pop(user_id)
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
try:
amount = int(text)
if amount <= 0:
raise ValueError
new_balance = await interface.balance_storage.add_balance(target_uid, amount)
await interface._send_temp(message, f"✅ Пополнено на {amount}. Баланс: {new_balance}")
except (ValueError, TypeError):
await interface._send_temp(message, "❌ Введите целое положительное число")
return
logger.info(f"Handle text: '{text[:50]}' from {user_id}, param_state={user_id in interface._user_input_state}")
@@ -546,6 +735,8 @@ class BotInterface:
await interface._process_param_input(message)
return
await interface._safe_delete(message.bot, message.chat.id, message.message_id)
if text.startswith('http://') or text.startswith('https://'):
await interface._add_visit(message)
return
@@ -699,7 +890,46 @@ class BotInterface:
# ОТОБРАЖЕНИЕ
# =========================================================================
async def _show_main_menu(self, message: Message):
async def _safe_delete(self, bot, chat_id: int, msg_id: int) -> None:
try:
await bot.delete_message(chat_id, msg_id)
except Exception:
pass
async def _send_temp(self, message: Message, text: str, delay: int = 5) -> None:
"""Отправляет сообщение и удаляет его через delay секунд."""
sent = await message.answer(text)
async def _delete():
await asyncio.sleep(delay)
await self._safe_delete(message.bot, message.chat.id, sent.message_id)
asyncio.create_task(_delete())
async def _clean_prev(self, message: Message, user_id: int = None) -> None:
"""Удаляет триггер-сообщение и все предыдущие меню пользователя."""
if user_id is None:
user_id = message.from_user.id if message.from_user else None
if not user_id:
return
if user_id in self._menu_top_msg:
await self._safe_delete(message.bot, message.chat.id, self._menu_top_msg.pop(user_id))
if user_id in self._menu_msg:
await self._safe_delete(message.bot, message.chat.id, self._menu_msg.pop(user_id))
await self._safe_delete(message.bot, message.chat.id, message.message_id)
async def _edit_or_send(self, message: Message, text: str, markup, edit: bool, user_id: int = None) -> None:
if edit:
try:
await message.edit_text(text, reply_markup=markup)
return
except Exception as e:
logger.debug(f"Edit message skipped: {e}")
await self._clean_prev(message, user_id=user_id)
sent = await message.answer(text, reply_markup=markup)
uid = user_id or (message.from_user.id if message.from_user else None)
if uid:
self._menu_msg[uid] = sent.message_id
async def _show_main_menu(self, message: Message, user_id: int = None):
"""Главное меню."""
stats = await self.task_manager.get_stats()
@@ -715,15 +945,22 @@ class BotInterface:
builder.row(InlineKeyboardButton(text=f"📺 Стримеры ({stats['twitch']})", callback_data="menu_streamers"))
builder.row(InlineKeyboardButton(text=f"📊 Задачи ({stats['total']})", callback_data="menu_tasks"))
builder.row(InlineKeyboardButton(text="📈 Статус", callback_data="menu_status"))
builder.row(InlineKeyboardButton(text="👥 Пользователи", callback_data="menu_users"))
builder.row(InlineKeyboardButton(text="🛑 Остановить всё", callback_data="stop_all"))
builder.row(InlineKeyboardButton(text="👤 Режим пользователя", callback_data="switch_to_user"))
reply = ReplyKeyboardBuilder()
reply.row(KeyboardButton(text="📺 Стримеры"), KeyboardButton(text="📊 Задачи"))
reply.row(KeyboardButton(text="📈 Статус"), KeyboardButton(text="🚀 Быстрый старт"))
reply.row(KeyboardButton(text="🛑 Остановить всё"), KeyboardButton(text="📋 Главное меню"))
reply.row(KeyboardButton(text="👥 Пользователи"), KeyboardButton(text="📈 Статус"))
reply.row(KeyboardButton(text="🚀 Быстрый старт"), KeyboardButton(text="🛑 Остановить всё"))
await message.answer(text, reply_markup=reply.as_markup(resize_keyboard=True))
await message.answer("💡 Кнопки управления:", reply_markup=builder.as_markup())
uid = user_id or (message.from_user.id if message.from_user else None)
await self._clean_prev(message, user_id=uid)
sent1 = await message.answer(text, reply_markup=reply.as_markup(resize_keyboard=True))
sent2 = await message.answer("💡 Кнопки управления:", reply_markup=builder.as_markup())
if uid:
self._menu_top_msg[uid] = sent1.message_id
self._menu_msg[uid] = sent2.message_id
async def _show_streamers_list(self, message: Message, edit: bool = False):
active = await self.task_manager.get_active_tasks()
@@ -772,13 +1009,7 @@ class BotInterface:
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
if edit:
try:
await message.edit_text(text, reply_markup=builder.as_markup())
except Exception as e:
logger.debug(f"Edit message skipped: {e}")
else:
await message.answer(text, reply_markup=builder.as_markup())
await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _show_tasks_list(self, message: Message, edit: bool = False):
"""Все задачи."""
@@ -786,20 +1017,12 @@ class BotInterface:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔄 Обновить", callback_data="menu_tasks"))
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
if edit:
try:
await message.edit_text(text, reply_markup=builder.as_markup())
except Exception as e:
logger.debug(f"Edit message skipped: {e}")
else:
await message.answer(text, reply_markup=builder.as_markup())
await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _show_status(self, message: Message, edit: bool = False):
"""Статус."""
stats = await self.task_manager.get_stats()
proxy_stats = self.proxy_manager.get_stats()
text = (
"📈 **СТАТУС**\n\n"
f"📺 Twitch: **{stats['twitch']}**\n"
@@ -808,18 +1031,10 @@ class BotInterface:
f"🔄 Активно: **{stats['active']}**\n\n"
f"🔌 Прокси: {proxy_stats['total']} всего, {proxy_stats['available']} доступно"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔄 Обновить", callback_data="menu_status"))
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
if edit:
try:
await message.edit_text(text, reply_markup=builder.as_markup())
except Exception as e:
logger.debug(f"Edit message skipped: {e}")
else:
await message.answer(text, reply_markup=builder.as_markup())
await self._edit_or_send(message, text, builder.as_markup(), edit)
# =========================================================================
# УПРАВЛЕНИЕ ЗАДАЧЕЙ
@@ -882,10 +1097,15 @@ class BotInterface:
user_id = message.from_user.id
state = self._user_input_state.pop(user_id)
# Удаляем prompt и ответ пользователя
prompt_id = state.get("prompt_msg_id")
if prompt_id:
await self._safe_delete(message.bot, message.chat.id, prompt_id)
await self._safe_delete(message.bot, message.chat.id, message.message_id)
task_id = state["task_id"]
param = state["param"]
# Получаем текст и очищаем от кавычек
value = message.text.strip() if message.text else ""
value = value.replace("'", "").replace('"', "").replace("`", "").strip()
@@ -953,9 +1173,14 @@ class BotInterface:
try:
await irc.listen_for_messages(on_url, params.monitor_duration, params.allowed_domains, is_active=is_active)
# Только для задач с лимитом времени (admin), не для бесконечных (user)
if params.monitor_minutes > 0:
await self.task_manager.complete_task(task_id)
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"IRC error [{params.channel}]: {e}")
# Не помечаем как завершённую — задача будет восстановлена при рестарте
finally:
await irc.disconnect()
if self.storage:
@@ -968,11 +1193,19 @@ class BotInterface:
try:
params.links_found += 1
from urllib.parse import urlparse
domain = urlparse(url).netloc
# Проверяем баланс для пользовательских задач
if params.user_id:
balance = await self.balance_storage.get_balance(params.user_id)
if balance <= 0:
params.stopped = True
await send_message_safe(
message.bot, params.chat_id,
"⚠️ Баланс исчерпан — задача остановлена.\n"
"Обратитесь к администратору для пополнения."
)
return
logger.info(f"🔗 @{username}: {url}")
await send_message_safe(
message.bot, message.chat.id,
f"🔗 @{username}: `{url[:60]}`"
@@ -981,9 +1214,10 @@ class BotInterface:
for i in range(params.visits_per_link):
while params.paused:
await asyncio.sleep(1)
if params.stopped:
break
reading = params.get_reading_time()
try:
result = await self.browser_service.visit_page_from_twitch(
url=url,
@@ -993,6 +1227,16 @@ class BotInterface:
params.total_visits += 1
if result.success:
params.successful_visits += 1
if params.user_id:
new_balance = await self.balance_storage.deduct(params.user_id, 1)
if new_balance <= 0:
params.stopped = True
await send_message_safe(
message.bot, params.chat_id,
"⚠️ Баланс исчерпан — задача остановлена.\n"
"Обратитесь к администратору для пополнения."
)
break
except Exception as e:
logger.error(f"Visit error: {e}")
@@ -1002,5 +1246,250 @@ class BotInterface:
except Exception as e:
logger.error(f"Process URL error: {e}")
# =========================================================================
# КАБИНЕТ ПОЛЬЗОВАТЕЛЯ
# =========================================================================
async def _show_user_menu(self, message: Message, edit: bool = False):
user_id = message.from_user.id
balance = await self.balance_storage.get_balance(user_id)
all_tasks = await self.task_manager.get_all_tasks()
active = sum(
1 for p in all_tasks.values()
if p.user_id == user_id and not p.completed and not p.stopped
)
text = (
"👤 **ЛИЧНЫЙ КАБИНЕТ**\n\n"
f"💰 Баланс: **{balance}** переходов\n"
f"📋 Активных задач: **{active}**\n\n"
"Выберите действие:"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="📝 Новая задача", callback_data="new_task"),
InlineKeyboardButton(text="📋 Мои задачи", callback_data="my_tasks"),
)
builder.row(InlineKeyboardButton(text="💰 Баланс", callback_data="my_balance"))
builder.row(InlineKeyboardButton(text="🔑 Режим администратора", callback_data="enter_admin_mode"))
await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _show_user_tasks(
self, message: Message, user_id: int,
edit: bool = False, for_admin: bool = False
):
all_tasks = await self.task_manager.get_all_tasks()
user_tasks = {tid: p for tid, p in all_tasks.items() if p.user_id == user_id}
if not user_tasks:
text = f"📋 **ЗАДАЧИ** {'пользователя `' + str(user_id) + '`' if for_admin else ''}\n\nЗадач нет."
else:
header = f"📋 **ЗАДАЧИ** `{user_id}`\n\n" if for_admin else "📋 **МОИ ЗАДАЧИ**\n\n"
text = header
for tid, p in list(user_tasks.items())[:10]:
domains = ", ".join(p.allowed_domains) if p.allowed_domains else "все"
text += (
f"{p.get_status_emoji()} 📺 `{p.channel}` — 🌐 {domains}\n"
f" 🔗 {p.links_found} | ✅ {p.successful_visits} | 📊 {p.total_visits}\n"
)
builder = InlineKeyboardBuilder()
if for_admin:
builder.row(InlineKeyboardButton(text="🔙 К пользователю", callback_data=f"udetail_{user_id}"))
else:
for tid, p in list(user_tasks.items())[:8]:
if not p.completed and not p.stopped:
pause_btn = (
InlineKeyboardButton(text="▶️", callback_data=f"uresume_{tid}")
if p.paused else
InlineKeyboardButton(text="⏸️", callback_data=f"upause_{tid}")
)
builder.row(
pause_btn,
InlineKeyboardButton(text="🌐 Домены", callback_data=f"uedit_{tid}"),
InlineKeyboardButton(text="🗑️", callback_data=f"ustop_{tid}"),
)
else:
builder.row(InlineKeyboardButton(
text=f"🗑️ {p.channel} (завершена)",
callback_data=f"ustop_{tid}"
))
builder.row(InlineKeyboardButton(text="📝 Новая задача", callback_data="new_task"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_menu"))
await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _process_user_task_input(self, message: Message):
user_id = message.from_user.id
state = self._user_task_state[user_id]
text = message.text.strip() if message.text else ""
if not text:
return
await self._safe_delete(message.bot, message.chat.id, message.message_id)
if state["step"] == "channel":
if not re.match(r'^[a-zA-Z0-9_]+$', text):
await message.answer("❌ Неверное название канала (только буквы, цифры, _). Попробуйте ещё раз:")
return
channel = text.lower()
# Проверка на дубликат
all_tasks = await self.task_manager.get_all_tasks()
duplicate = any(
p.user_id == user_id and p.channel == channel and not p.completed and not p.stopped
for p in all_tasks.values()
)
if duplicate:
await self._send_temp(
message,
f"❌ У вас уже есть активная задача для канала `{channel}`.\nВведите другой канал:"
)
return
# Удаляем промпт шага 1 перед отправкой шага 2
if user_id in self._menu_msg:
await self._safe_delete(message.bot, message.chat.id, self._menu_msg.pop(user_id))
self._user_task_state[user_id] = {"step": "domains", "channel": channel}
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_new_task"))
sent = await message.answer(
f"📺 Канал: `{channel}`\n\n"
"Шаг 2/2: Введите домены через запятую:\n"
"_Пример: `twitch.tv, youtube.com`_",
reply_markup=builder.as_markup()
)
self._menu_msg[user_id] = sent.message_id
elif state["step"] in ("domains", "edit_domains"):
domains = [d.strip().lower() for d in text.split(",") if d.strip()]
if not domains:
await self._send_temp(message, "❌ Введите хотя бы один домен. Пример: `twitch.tv, example.com`")
return
invalid = [d for d in domains if not re.match(r'^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', d)]
if invalid:
await self._send_temp(message, f"❌ Неверный формат: `{', '.join(invalid)}`\nПример: `twitch.tv, example.com`")
return
del self._user_task_state[user_id]
# Редактирование доменов существующей задачи
if state["step"] == "edit_domains":
task_id = state["task_id"]
await self._clean_prev(message)
await self.task_manager.update_task(task_id, allowed_domains=domains)
if self.storage:
params = await self.task_manager.get_task(task_id)
if params:
await self.storage.save_task(task_id, params)
await self._send_temp(message, f"✅ Домены обновлены: `{', '.join(domains)}`", delay=4)
await self._show_user_tasks(message, user_id)
return
# Удаляем промпт шага 2 перед любым ответом
await self._clean_prev(message)
# Создание новой задачи
balance = await self.balance_storage.get_balance(user_id)
if balance <= 0:
await self._send_temp(message, "❌ Недостаточно баланса. Обратитесь к администратору.")
return
channel = state["channel"]
task_id = f"u{user_id}_{channel}_{datetime.now().strftime('%H%M%S')}"
params = TaskParams(
task_type="twitch_irc",
channel=channel,
target_username="*",
allowed_domains=domains,
user_id=user_id,
visits_per_link=settings.DEFAULT_VISITS_PER_LINK,
monitor_minutes=0,
min_delay=settings.DEFAULT_MIN_DELAY,
max_delay=settings.DEFAULT_MAX_DELAY,
min_reading=settings.DEFAULT_MIN_READING,
max_reading=settings.DEFAULT_MAX_READING,
started_at=datetime.now(),
chat_id=message.chat.id,
)
await self.task_manager.add_task(task_id, params)
success = await self.background_tasks.start_task(
task_id=task_id,
coro=self._run_twitch(task_id, params, message),
task_type="twitch_irc",
metadata={"type": "twitch_irc", "channel": channel, "chat_id": message.chat.id},
)
if success:
if self.storage:
await self.storage.save_task(task_id, params)
await self._send_temp(
message,
f"✅ Мониторинг `{channel}` запущен!\n🌐 {', '.join(domains)}\n💰 Баланс: {balance}",
delay=6
)
await self._show_user_menu(message)
else:
await self._send_temp(message, "❌ Ошибка запуска задачи")
# =========================================================================
# ПАНЕЛЬ ПОЛЬЗОВАТЕЛЕЙ (ADMIN)
# =========================================================================
async def _show_users_list(self, message: Message, edit: bool = False):
balances = await self.balance_storage.get_all()
all_tasks = await self.task_manager.get_all_tasks()
text = "👥 **ПОЛЬЗОВАТЕЛИ**\n\n"
builder = InlineKeyboardBuilder()
if not balances:
text += "Нет пользователей с балансом."
else:
for uid_str, balance in list(balances.items())[:15]:
uid = int(uid_str)
active = sum(
1 for p in all_tasks.values()
if p.user_id == uid and not p.completed and not p.stopped
)
text += f"👤 `{uid}` — 💰 {balance} | 📋 {active} задач\n"
builder.row(InlineKeyboardButton(
text=f"👤 {uid} (💰{balance})",
callback_data=f"udetail_{uid}",
))
builder.row(InlineKeyboardButton(text="🔙 В меню", callback_data="menu_main"))
await self._edit_or_send(message, text, builder.as_markup(), edit)
async def _show_user_detail(self, callback: CallbackQuery, target_user_id: int):
balance = await self.balance_storage.get_balance(target_user_id)
all_tasks = await self.task_manager.get_all_tasks()
user_tasks = {tid: p for tid, p in all_tasks.items() if p.user_id == target_user_id}
active = sum(1 for p in user_tasks.values() if not p.completed and not p.stopped)
text = (
f"👤 **Пользователь** `{target_user_id}`\n\n"
f"💰 Баланс: **{balance}** переходов\n"
f"📋 Задач: {len(user_tasks)} (активных: {active})\n\n"
"Пополнить баланс:"
)
builder = InlineKeyboardBuilder()
for amount in [10, 50, 100, 500]:
builder.button(text=f"+{amount}", callback_data=f"ubal_{amount}_{target_user_id}")
builder.adjust(4)
builder.row(InlineKeyboardButton(
text="✏️ Другая сумма",
callback_data=f"ubal_custom_{target_user_id}",
))
if user_tasks:
builder.row(InlineKeyboardButton(
text=f"📋 Задачи ({len(user_tasks)})",
callback_data=f"utasks_{target_user_id}",
))
builder.row(InlineKeyboardButton(text="🔙 К списку", callback_data="menu_users"))
try:
await callback.message.edit_text(text, reply_markup=builder.as_markup())
except Exception:
await callback.message.answer(text, reply_markup=builder.as_markup())
+8 -1
View File
@@ -95,8 +95,15 @@ class BotApplication:
restored = 0
for task_id, params in saved_tasks.items():
if params.completed or params.stopped:
if params.stopped:
continue
# Задачи с лимитом времени, завершившиеся нормально — не восстанавливаем
if params.completed and params.monitor_minutes > 0:
continue
# Сбрасываем некорректный completed (баг IRC) для бесконечных задач
if params.completed:
params.completed = False
params.paused = False
await self.interface.task_manager.add_task(task_id, params)
+5 -10
View File
@@ -116,21 +116,16 @@ class BackgroundTaskManager:
Returns:
Number of tasks cancelled
"""
cancelled = 0
async with self._lock:
for task in list(self._tasks.values()):
if not task.done():
running = [t for t in self._tasks.values() if not t.done()]
for task in running:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
cancelled += 1
if running:
await asyncio.gather(*running, return_exceptions=True)
self._tasks.clear()
self._task_info.clear()
cancelled = len(running)
logger.info(f"All tasks cancelled: {cancelled}")
return cancelled
+53
View File
@@ -44,6 +44,7 @@ class TaskStorage:
"successful_visits": params.successful_visits,
"links_found": params.links_found,
"chat_id": params.chat_id,
"user_id": params.user_id,
"started_at": params.started_at.isoformat() if params.started_at else None,
}
@@ -69,6 +70,7 @@ class TaskStorage:
successful_visits=data.get("successful_visits", 0),
links_found=data.get("links_found", 0),
chat_id=data.get("chat_id"),
user_id=data.get("user_id"),
)
if data.get("started_at"):
try:
@@ -84,8 +86,12 @@ class TaskStorage:
def _load_sync(self) -> dict:
if not self.file_path.exists():
return {}
try:
with open(self.file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load tasks: {e}")
return {}
async def save_tasks(self, tasks: Dict[str, TaskParams]) -> None:
async with self._lock:
@@ -165,3 +171,50 @@ class ChatStorage:
if key in data and channel in data[key]:
data[key].remove(channel)
await asyncio.to_thread(self._save_sync, data)
class BalanceStorage:
"""Баланс переходов пользователей."""
def __init__(self, file_path: str = "data/balances.json"):
self.file_path = Path(file_path)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
def _load_sync(self) -> dict:
if not self.file_path.exists():
return {}
try:
with open(self.file_path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load balances: {e}")
return {}
def _save_sync(self, data: dict) -> None:
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
async def get_balance(self, user_id: int) -> int:
async with self._lock:
data = await asyncio.to_thread(self._load_sync)
return data.get(str(user_id), 0)
async def add_balance(self, user_id: int, amount: int) -> int:
async with self._lock:
data = await asyncio.to_thread(self._load_sync)
data[str(user_id)] = data.get(str(user_id), 0) + amount
await asyncio.to_thread(self._save_sync, data)
return data[str(user_id)]
async def deduct(self, user_id: int, amount: int = 1) -> int:
async with self._lock:
data = await asyncio.to_thread(self._load_sync)
new_bal = max(0, data.get(str(user_id), 0) - amount)
data[str(user_id)] = new_bal
await asyncio.to_thread(self._save_sync, data)
return new_bal
async def get_all(self) -> dict:
async with self._lock:
return await asyncio.to_thread(self._load_sync)
+15 -7
View File
@@ -48,6 +48,7 @@ class TaskParams:
successful_visits: int = 0
links_found: int = 0
chat_id: Optional[int] = None
user_id: Optional[int] = None # Telegram user_id, если задача создана пользователем
completed: bool = False # Задача завершена
stopped: bool = False # Задача остановлена пользователем
@@ -241,15 +242,22 @@ class TaskManager:
async def get_stats(self) -> dict:
"""Общая статистика."""
twitch_tasks = await self.get_twitch_tasks()
visit_tasks = await self.get_visit_tasks()
twitch = visit = paused = active = 0
for p in self._tasks.values():
if p.task_type == "twitch_irc":
twitch += 1
elif p.task_type == "visit":
visit += 1
if p.paused:
paused += 1
else:
active += 1
return {
"total": len(self._tasks),
"twitch": len(twitch_tasks),
"visit": len(visit_tasks),
"paused": sum(1 for p in self._tasks.values() if p.paused),
"active": sum(1 for p in self._tasks.values() if not p.paused),
"twitch": twitch,
"visit": visit,
"paused": paused,
"active": active,
}
def _get_task_info(self, task_id: str, params: TaskParams) -> str:
+15 -3
View File
@@ -41,9 +41,12 @@ class TwitchIRCClient:
try:
import ssl
ssl_context = ssl.create_default_context()
self._reader, self._writer = await asyncio.open_connection(
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(
settings.IRC_SERVER, settings.IRC_PORT,
ssl=ssl_context
),
timeout=15.0
)
await self._send(f"PASS {self.irc_oauth}")
@@ -173,13 +176,21 @@ class TwitchIRCClient:
start_time = datetime.now()
last_message_time = datetime.now()
await self.connect()
if not await self.connect():
raise ConnectionError(f"IRC connect failed for #{self.channel}")
while (datetime.now() - start_time).seconds < duration:
while duration == 0 or (datetime.now() - start_time).seconds < duration:
# === ПРОВЕРКА АКТИВНОСТИ ===
if is_active and not is_active():
logger.info("⏸️ Task paused/stopped, disconnecting...")
break
# Нет соединения — ждём перед повтором
if not self._connected:
await asyncio.sleep(5)
await self.connect()
continue
# === ПЕРЕПОДКЛЮЧЕНИЕ КАЖДЫЕ 30 СЕКУНД ===
if (datetime.now() - last_message_time).seconds > 30:
logger.info("🔄 Reconnecting (30s)...")
@@ -227,6 +238,7 @@ class TwitchIRCClient:
continue
if not line:
await asyncio.sleep(0.5)
continue
if line.startswith('PONG'):