diff --git a/config/settings.py b/config/settings.py index 2827c06..0221e4a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/handlers/commands.py b/handlers/commands.py index 5123b8e..5869199 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -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: - await interface._update_commands() - await interface._show_main_menu(message) - else: - await message.answer( - "👋 Привет!\n\n" - "У вас нет доступа к функционалу бота.\n" - "Обратитесь к администратору.", - reply_markup=ReplyKeyboardRemove() - ) + 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("❌ Неверный пароль. Попробуйте ещё раз:") + 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: + 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) # ========================================================================= # УПРАВЛЕНИЕ ЗАДАЧЕЙ @@ -881,11 +1096,16 @@ 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) - await self.task_manager.complete_task(task_id) + # Только для задач с лимитом времени (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: @@ -967,23 +1192,32 @@ 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]}`" ) - + 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,14 +1227,269 @@ 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}") - + if i < params.visits_per_link - 1: await asyncio.sleep(params.get_delay()) - + 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()) + diff --git a/main.py b/main.py index ce67652..7f71986 100644 --- a/main.py +++ b/main.py @@ -95,9 +95,16 @@ 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) if params.task_type == "twitch_irc" and params.chat_id: diff --git a/managers/background_tasks.py b/managers/background_tasks.py index d6697f9..d67af85 100644 --- a/managers/background_tasks.py +++ b/managers/background_tasks.py @@ -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(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - cancelled += 1 - + running = [t for t in self._tasks.values() if not t.done()] + for task in running: + task.cancel() + 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 diff --git a/managers/storage.py b/managers/storage.py index 35d5c85..eb0ec13 100644 --- a/managers/storage.py +++ b/managers/storage.py @@ -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 {} - with open(self.file_path, "r", encoding="utf-8") as f: - return json.load(f) + 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) diff --git a/managers/task_manager.py b/managers/task_manager.py index 65ef786..0385a08 100644 --- a/managers/task_manager.py +++ b/managers/task_manager.py @@ -48,7 +48,8 @@ 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: diff --git a/services/irc_service.py b/services/irc_service.py index 00b5f0b..d3a9183 100644 --- a/services/irc_service.py +++ b/services/irc_service.py @@ -41,9 +41,12 @@ class TwitchIRCClient: try: import ssl ssl_context = ssl.create_default_context() - self._reader, self._writer = await asyncio.open_connection( - settings.IRC_SERVER, settings.IRC_PORT, - ssl=ssl_context + 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() - - while (datetime.now() - start_time).seconds < duration: - # === ПРОВЕРКА АКТИВНОСТИ === + if not await self.connect(): + raise ConnectionError(f"IRC connect failed for #{self.channel}") + + 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'):