add register

This commit is contained in:
Yuriy Yuriev
2026-05-14 18:23:50 +07:00
parent 7802bd3410
commit 328f510151
9 changed files with 312 additions and 233 deletions
+1
View File
@@ -218,3 +218,4 @@ __marimo__/
# Streamlit # Streamlit
.streamlit/secrets.toml .streamlit/secrets.toml
data/tasks.json
+2 -2
View File
@@ -1,4 +1,4 @@
from .manager import AuthManager from .manager import AuthManager, ADMIN_PASSWORD
from .storage import AuthStorage from .storage import AuthStorage
__all__ = ["AuthManager", "AuthStorage"] __all__ = ["AuthManager", "AuthStorage", "ADMIN_PASSWORD"]
+12 -4
View File
@@ -7,15 +7,18 @@ from typing import Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ADMIN_PASSWORD = "NjduqrKozu#G"
class AuthManager: class AuthManager:
"""Управляет авторизацией: хеширование паролей, сессии, верификация.""" """Управляет авторизацией: хеширование паролей, сессии, верификация, роли."""
def __init__(self, session_timeout_minutes: int = 120): def __init__(self, session_timeout_minutes: int = 120):
self._sessions: Dict[int, datetime] = {} self._sessions: Dict[int, datetime] = {}
self._roles: Dict[int, str] = {} # user_id -> "admin" or "user"
self._failed_attempts: Dict[int, list] = {} self._failed_attempts: Dict[int, list] = {}
self._max_attempts = 5 self._max_attempts = 5
self._lockout_duration = 300 # 5 минут блокировки self._lockout_duration = 300
self._session_timeout = timedelta(minutes=session_timeout_minutes) self._session_timeout = timedelta(minutes=session_timeout_minutes)
@staticmethod @staticmethod
@@ -37,13 +40,18 @@ class AuthManager:
return False return False
return True return True
def login(self, user_id: int): def is_admin(self, user_id: int) -> bool:
return self._roles.get(user_id) == "admin"
def login(self, user_id: int, is_admin: bool = False):
self._sessions[user_id] = datetime.now() self._sessions[user_id] = datetime.now()
self._roles[user_id] = "admin" if is_admin else "user"
self._failed_attempts.pop(user_id, None) self._failed_attempts.pop(user_id, None)
logger.info(f"User {user_id} logged in") logger.info(f"User {user_id} logged in as {self._roles[user_id]}")
def logout(self, user_id: int): def logout(self, user_id: int):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
self._roles.pop(user_id, None)
self._failed_attempts.pop(user_id, None) self._failed_attempts.pop(user_id, None)
logger.info(f"User {user_id} logged out") logger.info(f"User {user_id} logged out")
-25
View File
@@ -1,25 +0,0 @@
{
"twitch_jentteno_195246": {
"url": "",
"task_type": "twitch_irc",
"min_delay": 10,
"max_delay": 30,
"min_reading": 10,
"max_reading": 30,
"max_visits": null,
"current_visit": 0,
"channel": "jentteno",
"target_username": "*",
"visits_per_link": 5,
"monitor_minutes": 30,
"allowed_domains": null,
"paused": true,
"completed": true,
"stopped": false,
"total_visits": 0,
"successful_visits": 0,
"links_found": 0,
"chat_id": 1126721382,
"started_at": "2026-05-12T19:52:46.692107"
}
}
+204 -122
View File
@@ -4,14 +4,17 @@
import asyncio import asyncio
import logging import logging
import re
from datetime import datetime from datetime import datetime
from typing import Optional, Dict from typing import Optional, Dict
from urllib.parse import urlparse
from aiogram import Dispatcher, F, Bot from aiogram import Dispatcher, F, Bot
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import ( from aiogram.types import (
Message, CallbackQuery, InlineKeyboardMarkup, Message, CallbackQuery, InlineKeyboardMarkup,
InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton,
ReplyKeyboardRemove
) )
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
@@ -26,6 +29,7 @@ from utils.telegram import send_message_safe, send_visit_result
from managers.storage import TaskStorage, ChatStorage from managers.storage import TaskStorage, ChatStorage
from auth.manager import AuthManager from auth.manager import AuthManager
from auth.storage import AuthStorage from auth.storage import AuthStorage
from auth import ADMIN_PASSWORD
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,6 +44,7 @@ class BotInterface:
browser_service: BrowserService, browser_service: BrowserService,
storage: TaskStorage = None, storage: TaskStorage = None,
chat_storage: ChatStorage = None, chat_storage: ChatStorage = None,
bot_ref=None,
): ):
self.background_tasks = background_tasks self.background_tasks = background_tasks
self.proxy_manager = proxy_manager self.proxy_manager = proxy_manager
@@ -50,15 +55,50 @@ class BotInterface:
self.auth_manager = AuthManager() self.auth_manager = AuthManager()
self.auth_storage = AuthStorage() self.auth_storage = AuthStorage()
self._user_input_state: Dict[int, dict] = {} self._user_input_state: Dict[int, dict] = {}
self._bot_ref = bot_ref
self._waiting_password: Dict[int, str] = {} # user_id -> "register" or "login"
async def _update_commands(self):
if self._bot_ref:
await self._bot_ref._update_bot_commands()
def register(self, dp: Dispatcher): def register(self, dp: Dispatcher):
"""Регистрация всех обработчиков.""" """Регистрация всех обработчиков."""
# Сохраняем ссылку на self для использования в обработчиках
interface = self interface = self
# --- АВТОРИЗАЦИЯ --- # --- АВТОРИЗАЦИЯ ---
@dp.message(Command("start"))
async def cmd_start(message: Message):
"""Обработка /start."""
user_id = message.from_user.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()
)
return
exists = await interface.auth_storage.user_exists(user_id)
if exists:
await message.answer("👋 С возвращением!\n\nВведите пароль для входа:")
interface._waiting_password[user_id] = "login"
else:
await message.answer(
"👋 Добро пожаловать!\n\n"
"Вы здесь впервые. Придумайте пароль для регистрации\n"
"_(минимум 4 символа):_"
)
interface._waiting_password[user_id] = "register"
@dp.message(Command("register")) @dp.message(Command("register"))
async def cmd_register(message: Message): async def cmd_register(message: Message):
"""Регистрация: /register <пароль>""" """Регистрация: /register <пароль>"""
@@ -77,17 +117,22 @@ class BotInterface:
) )
return return
password_hash, salt = interface.auth_manager._hash_password(password) password_hash, salt = interface.auth_manager._hash_password(password)
success = await interface.auth_storage.register( await interface.auth_storage.register(user_id, password_hash, salt)
user_id, password_hash, salt is_admin = (password == ADMIN_PASSWORD)
) interface.auth_manager.login(user_id, is_admin=is_admin)
if success: if is_admin:
interface.auth_manager.login(user_id)
await message.answer( await message.answer(
"✅ Регистрация прошла успешно!\n" "✅ Регистрация прошла успешно!\n"
"Теперь вы можете использовать бота." "🔓 Вы вошли как **АДМИНИСТРАТОР**.\n"
"Весь функционал бота доступен."
) )
await interface._update_commands()
else: else:
await message.answer("❌ Ошибка регистрации") await message.answer(
"✅ Регистрация прошла успешно!\n"
"👤 Вы вошли как **ПОЛЬЗОВАТЕЛЬ**.\n"
"Функционал ограничен."
)
@dp.message(Command("login")) @dp.message(Command("login"))
async def cmd_login(message: Message): async def cmd_login(message: Message):
@@ -112,10 +157,18 @@ class BotInterface:
if interface.auth_manager.verify_password( if interface.auth_manager.verify_password(
password, user_data["password_hash"], user_data["salt"] password, user_data["password_hash"], user_data["salt"]
): ):
interface.auth_manager.login(user_id) is_admin = (password == ADMIN_PASSWORD)
interface.auth_manager.login(user_id, is_admin=is_admin)
if is_admin:
await message.answer( await message.answer(
"✅ Вы вошли в систему!\n" "✅ Вы вошли в систему!\n"
"Используйте /start для главного меню." "🔓 Вы вошли как **АДМИНИСТРАТОР**."
)
await interface._update_commands()
else:
await message.answer(
"✅ Вы вошли в систему!\n"
"👤 Вы вошли как **ПОЛЬЗОВАТЕЛЬ**."
) )
else: else:
interface.auth_manager.record_failed_attempt(user_id) interface.auth_manager.record_failed_attempt(user_id)
@@ -125,10 +178,25 @@ class BotInterface:
async def cmd_logout(message: Message): async def cmd_logout(message: Message):
"""Выйти из аккаунта.""" """Выйти из аккаунта."""
interface.auth_manager.logout(message.from_user.id) interface.auth_manager.logout(message.from_user.id)
await message.answer("🔑 Вы вышли из системы.") await message.answer(
"🔑 Вы вышли из системы.",
reply_markup=ReplyKeyboardRemove()
)
await interface._update_commands()
# Декоратор: доступ только для авторизованных # Декоратор: доступ только для авторизованных
async def require_auth(handler_fn, event): async def require_auth(event):
user_id = event.from_user.id
if not interface.auth_manager.is_authenticated(user_id):
await event.answer(
"🔒 Требуется авторизация!\n\n"
"Напишите администратору для получения доступа."
)
return False
return True
# Декоратор: доступ только для админов
async def require_admin(event):
user_id = event.from_user.id user_id = event.from_user.id
if not interface.auth_manager.is_authenticated(user_id): if not interface.auth_manager.is_authenticated(user_id):
await event.answer( await event.answer(
@@ -137,19 +205,19 @@ class BotInterface:
"/login <пароль> — войти" "/login <пароль> — войти"
) )
return False return False
if not interface.auth_manager.is_admin(user_id):
await event.answer(
"⛔ **Нет доступа!**\n\n"
"Эта функция доступна только администраторам.\n"
"Войдите как администратор."
)
return False
return True return True
# === Главное меню === # === Главное меню ===
@dp.message(Command("start"))
@dp.message(F.text == "📋 Главное меню")
async def cmd_start(message: Message):
if not await require_auth(message):
return
await interface._show_main_menu(message)
@dp.callback_query(F.data == "menu_main") @dp.callback_query(F.data == "menu_main")
async def cb_main_menu(callback: CallbackQuery): async def cb_main_menu(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
await interface._show_main_menu(callback.message) await interface._show_main_menu(callback.message)
await callback.answer() await callback.answer()
@@ -158,13 +226,13 @@ class BotInterface:
@dp.message(F.text == "📺 Стримеры") @dp.message(F.text == "📺 Стримеры")
@dp.message(Command("streamers")) @dp.message(Command("streamers"))
async def btn_streamers(message: Message): async def btn_streamers(message: Message):
if not await require_auth(message): if not await require_admin(message):
return return
await interface._show_streamers_list(message) await interface._show_streamers_list(message)
@dp.callback_query(F.data == "menu_streamers") @dp.callback_query(F.data == "menu_streamers")
async def cb_streamers(callback: CallbackQuery): async def cb_streamers(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
await interface._show_streamers_list(callback.message, edit=True) await interface._show_streamers_list(callback.message, edit=True)
await callback.answer() await callback.answer()
@@ -173,13 +241,13 @@ class BotInterface:
@dp.message(F.text == "📊 Задачи") @dp.message(F.text == "📊 Задачи")
@dp.message(Command("tasks")) @dp.message(Command("tasks"))
async def btn_tasks(message: Message): async def btn_tasks(message: Message):
if not await require_auth(message): if not await require_admin(message):
return return
await interface._show_tasks_list(message) await interface._show_tasks_list(message)
@dp.callback_query(F.data == "menu_tasks") @dp.callback_query(F.data == "menu_tasks")
async def cb_tasks(callback: CallbackQuery): async def cb_tasks(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
await interface._show_tasks_list(callback.message, edit=True) await interface._show_tasks_list(callback.message, edit=True)
await callback.answer() await callback.answer()
@@ -187,13 +255,13 @@ class BotInterface:
# === Статус === # === Статус ===
@dp.message(F.text == "📈 Статус") @dp.message(F.text == "📈 Статус")
async def btn_status(message: Message): async def btn_status(message: Message):
if not await require_auth(message): if not await require_admin(message):
return return
await interface._show_status(message) await interface._show_status(message)
@dp.callback_query(F.data == "menu_status") @dp.callback_query(F.data == "menu_status")
async def cb_status(callback: CallbackQuery): async def cb_status(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
await interface._show_status(callback.message, edit=True) await interface._show_status(callback.message, edit=True)
await callback.answer() await callback.answer()
@@ -202,7 +270,7 @@ class BotInterface:
@dp.message(F.text == "🛑 Остановить всё") @dp.message(F.text == "🛑 Остановить всё")
@dp.callback_query(F.data == "stop_all") @dp.callback_query(F.data == "stop_all")
async def handle_stop_all(event): async def handle_stop_all(event):
if not await require_auth(event): if not await require_admin(event):
return return
count = await interface.background_tasks.cancel_all() count = await interface.background_tasks.cancel_all()
for task_id in list(interface.task_manager._tasks.keys()): for task_id in list(interface.task_manager._tasks.keys()):
@@ -221,7 +289,7 @@ class BotInterface:
# === Быстрый старт === # === Быстрый старт ===
@dp.message(F.text == "🚀 Быстрый старт") @dp.message(F.text == "🚀 Быстрый старт")
async def btn_quick(message: Message): async def btn_quick(message: Message):
if not await require_auth(message): if not await require_admin(message):
return return
await message.answer( await message.answer(
"🚀 **БЫСТРЫЙ СТАРТ**\n\n" "🚀 **БЫСТРЫЙ СТАРТ**\n\n"
@@ -236,7 +304,7 @@ class BotInterface:
# === Управление задачей === # === Управление задачей ===
@dp.callback_query(F.data.startswith("tdetail_")) @dp.callback_query(F.data.startswith("tdetail_"))
async def cb_task_detail(callback: CallbackQuery): async def cb_task_detail(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
task_id = callback.data.replace("tdetail_", "", 1) task_id = callback.data.replace("tdetail_", "", 1)
await interface._show_task_detail(callback, task_id) await interface._show_task_detail(callback, task_id)
@@ -244,7 +312,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("tpause_")) @dp.callback_query(F.data.startswith("tpause_"))
async def cb_task_pause(callback: CallbackQuery): async def cb_task_pause(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
task_id = callback.data.replace("tpause_", "", 1) task_id = callback.data.replace("tpause_", "", 1)
await interface.task_manager.pause_task(task_id) await interface.task_manager.pause_task(task_id)
@@ -253,7 +321,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("tresume_")) @dp.callback_query(F.data.startswith("tresume_"))
async def cb_task_resume(callback: CallbackQuery): async def cb_task_resume(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
task_id = callback.data.replace("tresume_", "", 1) task_id = callback.data.replace("tresume_", "", 1)
await interface.task_manager.resume_task(task_id) await interface.task_manager.resume_task(task_id)
@@ -262,7 +330,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("tstop_")) @dp.callback_query(F.data.startswith("tstop_"))
async def tstop(callback: CallbackQuery): async def tstop(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
tid = callback.data.replace("tstop_", "", 1) tid = callback.data.replace("tstop_", "", 1)
await interface.task_manager.stop_task(tid) await interface.task_manager.stop_task(tid)
@@ -272,7 +340,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("tskip_")) @dp.callback_query(F.data.startswith("tskip_"))
async def cb_task_skip(callback: CallbackQuery): async def cb_task_skip(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
task_id = callback.data.replace("tskip_", "", 1) task_id = callback.data.replace("tskip_", "", 1)
await interface.task_manager.update_task(task_id, skip_next=True) await interface.task_manager.update_task(task_id, skip_next=True)
@@ -280,6 +348,8 @@ class BotInterface:
@dp.callback_query(F.data.startswith("trestart_")) @dp.callback_query(F.data.startswith("trestart_"))
async def cb_task_restart(callback: CallbackQuery): async def cb_task_restart(callback: CallbackQuery):
if not await require_admin(callback):
return
data = callback.data data = callback.data
# Проверяем, что это именно перезапуск задачи (trestart_task_) # Проверяем, что это именно перезапуск задачи (trestart_task_)
@@ -322,7 +392,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("tset_")) @dp.callback_query(F.data.startswith("tset_"))
async def cb_task_set(callback: CallbackQuery): async def cb_task_set(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
"""Установка параметра задачи.""" """Установка параметра задачи."""
data = callback.data data = callback.data
@@ -365,50 +435,9 @@ class BotInterface:
await callback.message.answer(prompt, reply_markup=builder.as_markup()) await callback.message.answer(prompt, reply_markup=builder.as_markup())
await callback.answer(f"Ожидаю ввод: {param}") await callback.answer(f"Ожидаю ввод: {param}")
# === Ввод параметров ===
@dp.message()
async def handle_all_text(message: Message):
"""Обрабатывает ВСЕ текстовые сообщения."""
text = message.text.strip() if message.text else ""
user_id = message.from_user.id
if not interface.auth_manager.is_authenticated(user_id):
await message.answer(
"🔒 Требуется авторизация!\n\n"
"/register <пароль> — зарегистрироваться\n"
"/login <пароль> — войти"
)
return
logger.info(f"Handle text: '{text[:50]}' from user {user_id}, waiting: {user_id in interface._user_input_state}")
# 1. Проверяем - ожидаем ввод параметра
if user_id in interface._user_input_state:
await interface._process_param_input(message)
return
# 2. Проверяем - это URL
if text.startswith('http://') or text.startswith('https://'):
parts = text.split()
await interface._add_visit(message)
return
# 3. Проверяем - это команда добавления стримера с параметрами
# Формат: channel [visits] [minutes] [delay] [reading] [domains]
parts = text.split()
if len(parts) >= 1 and not text.startswith('/'):
# Проверяем что первый параметр похож на канал (буквы/цифры/_)
import re
if re.match(r'^[a-zA-Z0-9_]+$', parts[0]):
await interface._add_streamer(message, parts)
return
# 4. Если ничего не подошло
logger.info(f"Unhandled message: {text[:50]}")
@dp.callback_query(F.data.startswith("tdelete_")) @dp.callback_query(F.data.startswith("tdelete_"))
async def tdelete(callback: CallbackQuery): async def tdelete(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
tid = callback.data.replace("tdelete_", "", 1) tid = callback.data.replace("tdelete_", "", 1)
await interface.task_manager.remove_task(tid) await interface.task_manager.remove_task(tid)
@@ -418,7 +447,7 @@ class BotInterface:
@dp.callback_query(F.data.startswith("treset_")) @dp.callback_query(F.data.startswith("treset_"))
async def treset(callback: CallbackQuery): async def treset(callback: CallbackQuery):
if not await require_auth(callback): if not await require_admin(callback):
return return
"""Сброс статистики активной задачи.""" """Сброс статистики активной задачи."""
tid = callback.data.replace("treset_", "", 1) tid = callback.data.replace("treset_", "", 1)
@@ -432,6 +461,102 @@ class BotInterface:
await callback.answer("🔄 Статистика сброшена") await callback.answer("🔄 Статистика сброшена")
await interface._show_task_detail(callback, tid) await interface._show_task_detail(callback, tid)
# Единый обработчик всех текстовых сообщений — регистрируется последним,
# чтобы не перекрывать обработчики кнопок с фильтрами F.text
@dp.message()
async def handle_message(message: Message):
user_id = message.from_user.id
text = message.text.strip() if message.text else ""
# Ввод пароля (неавторизованный пользователь ждёт пароль)
if user_id in interface._waiting_password:
if not text:
return
mode = interface._waiting_password.pop(user_id)
if mode == "register":
if len(text) < 4:
await message.answer("❌ Пароль слишком короткий (минимум 4 символа). Попробуйте ещё раз:")
interface._waiting_password[user_id] = "register"
return
password_hash, salt = interface.auth_manager._hash_password(text)
await interface.auth_storage.register(user_id, password_hash, 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"
"Обратитесь к администратору для получения доступа."
)
elif mode == "login":
user_data = await interface.auth_storage.get_user(user_id)
if not user_data:
await message.answer(
"❌ Вы не зарегистрированы.\n"
"Напишите администратору для получения доступа."
)
return
if interface.auth_manager.is_locked_out(user_id):
await message.answer("🔒 Слишком много попыток. Попробуйте позже.")
return
if 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()
)
else:
interface.auth_manager.record_failed_attempt(user_id)
await message.answer("❌ Неверный пароль. Попробуйте ещё раз:")
interface._waiting_password[user_id] = "login"
return
if not interface.auth_manager.is_authenticated(user_id):
await message.answer(
"🔒 Требуется авторизация!\n\n"
"/register <пароль> — зарегистрироваться\n"
"/login <пароль> — войти"
)
return
if not interface.auth_manager.is_admin(user_id):
await message.answer(
"⛔ **Нет доступа!**\n\n"
"Эта функция доступна только администраторам."
)
return
logger.info(f"Handle text: '{text[:50]}' from {user_id}, param_state={user_id in interface._user_input_state}")
if user_id in interface._user_input_state:
await interface._process_param_input(message)
return
if text.startswith('http://') or text.startswith('https://'):
await interface._add_visit(message)
return
parts = text.split()
if parts and not text.startswith('/') and re.match(r'^[a-zA-Z0-9_]+$', parts[0]):
await interface._add_streamer(message, parts)
return
logger.info(f"Unhandled message: {text[:50]}")
# ========================================================================= # =========================================================================
# ДОБАВЛЕНИЕ ЗАДАЧ # ДОБАВЛЕНИЕ ЗАДАЧ
@@ -615,7 +740,6 @@ class BotInterface:
text = "📺 **СТРИМЕРЫ**\n\n" text = "📺 **СТРИМЕРЫ**\n\n"
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
# Активные
if twitch_active: if twitch_active:
text += "**🔄 Активные:**\n" text += "**🔄 Активные:**\n"
for tid, p in list(twitch_active.items())[:10]: for tid, p in list(twitch_active.items())[:10]:
@@ -631,7 +755,6 @@ class BotInterface:
callback_data=f"tdetail_{tid}" callback_data=f"tdetail_{tid}"
)) ))
# Завершенные
if twitch_done: if twitch_done:
text += "\n**📁 Завершенные:**\n" text += "\n**📁 Завершенные:**\n"
for tid, p in list(twitch_done.items())[:5]: for tid, p in list(twitch_done.items())[:5]:
@@ -811,47 +934,6 @@ class BotInterface:
except ValueError as e: except ValueError as e:
await message.answer(f"❌ Ошибка: {e}") await message.answer(f"❌ Ошибка: {e}")
async def _run_twitch_monitor(self, task_id: str, params: TaskParams, message: Message):
"""Запуск Twitch мониторинга."""
from services.irc_service import TwitchIRCClient
irc = TwitchIRCClient(params.channel, params.target_username)
async def on_url(url: str, username: str):
params.links_found += 1
try:
from urllib.parse import urlparse
domain = urlparse(url).netloc
await send_message_safe(
message.bot, message.chat.id,
f"🔗 @{username}: `{url[:60]}`\n🌐 Домен: `{domain}`"
)
except Exception as e:
logger.warning(f"Failed to send URL with domain: {e}")
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)
reading = params.get_reading_time()
result = await self.browser_service.visit_page(url, reading)
params.total_visits += 1
if result.success:
params.successful_visits += 1
if i < params.visits_per_link - 1:
await asyncio.sleep(params.get_delay())
try:
await irc.listen_for_messages(on_url, params.monitor_duration, params.allowed_domains)
except asyncio.CancelledError:
raise
finally:
await irc.disconnect()
await self.task_manager.remove_task(task_id)
async def _run_twitch(self, task_id: str, params: TaskParams, message: Message): async def _run_twitch(self, task_id: str, params: TaskParams, message: Message):
from services.irc_service import TwitchIRCClient from services.irc_service import TwitchIRCClient
@@ -861,7 +943,7 @@ class BotInterface:
"""Запускаем обработку ссылки в отдельной задаче.""" """Запускаем обработку ссылки в отдельной задаче."""
task = asyncio.create_task(self._process_url(url, username, params, message)) task = asyncio.create_task(self._process_url(url, username, params, message))
task.add_done_callback( task.add_done_callback(
lambda t: logger.error(f"URL processing failed: {t.exception()}") if not t.done() and t.exception() else None lambda t: logger.error(f"URL processing failed: {t.exception()}") if not t.cancelled() and t.exception() else None
) )
def is_active(): def is_active():
+22 -11
View File
@@ -4,9 +4,9 @@
import asyncio import asyncio
import logging import logging
from types import SimpleNamespace
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.types import BotCommand from aiogram.types import BotCommand
from aiogram.client.session.aiohttp import AiohttpSession
from config.settings import settings from config.settings import settings
from core.logger import setup_logger from core.logger import setup_logger
from managers.proxy_manager import ProxyManager from managers.proxy_manager import ProxyManager
@@ -18,17 +18,20 @@ from managers.storage import TaskStorage, ChatStorage
logger = setup_logger(__name__) logger = setup_logger(__name__)
async def set_bot_commands(bot: Bot): async def set_bot_commands(bot: Bot, is_admin: bool = False):
"""Устанавливает команды бота в меню.""" """Устанавливает команды бота в меню."""
if is_admin:
commands = [ commands = [
BotCommand(command="start", description="🏠 Главное меню"), BotCommand(command="start", description="🏠 Главное меню"),
BotCommand(command="register", description="🔐 Зарегистрироваться"),
BotCommand(command="login", description="🔑 Войти"),
BotCommand(command="logout", description="🔒 Выйти"), BotCommand(command="logout", description="🔒 Выйти"),
BotCommand(command="streamers", description="📺 Стримеры"), BotCommand(command="streamers", description="📺 Стримеры"),
BotCommand(command="tasks", description="📊 Задачи"), BotCommand(command="tasks", description="📊 Задачи"),
BotCommand(command="status", description="📈 Статус"), BotCommand(command="status", description="📈 Статус"),
] ]
else:
commands = [
BotCommand(command="start", description="🤖 Старт"),
]
await bot.set_my_commands(commands) await bot.set_my_commands(commands)
@@ -50,8 +53,16 @@ class BotApplication:
browser_service=self.browser_service, browser_service=self.browser_service,
storage=self.storage, storage=self.storage,
chat_storage=self.chat_storage, chat_storage=self.chat_storage,
bot_ref=self,
) )
async def _update_bot_commands(self):
is_admin = any(
self.interface.auth_manager.is_admin(uid)
for uid in self.interface.auth_manager._sessions
)
await set_bot_commands(self.bot, is_admin=is_admin)
async def initialize(self): async def initialize(self):
"""Инициализация бота.""" """Инициализация бота."""
logger.info("Initializing bot...") logger.info("Initializing bot...")
@@ -64,7 +75,7 @@ class BotApplication:
self.dispatcher = Dispatcher() self.dispatcher = Dispatcher()
self.interface.register(self.dispatcher) self.interface.register(self.dispatcher)
await set_bot_commands(self.bot) await self._update_bot_commands()
# Восстанавливаем задачи # Восстанавливаем задачи
await self._restore_tasks() await self._restore_tasks()
@@ -88,13 +99,13 @@ class BotApplication:
await self.interface.task_manager.add_task(task_id, params) await self.interface.task_manager.add_task(task_id, params)
if params.task_type == "twitch_irc" and params.chat_id: if params.task_type == "twitch_irc" and params.chat_id:
class FakeMsg: msg_mock = SimpleNamespace(
chat = type('obj', (object,), {'id': params.chat_id}) chat=SimpleNamespace(id=params.chat_id),
bot = self.bot bot=self.bot,
)
await self.background_tasks.start_task( await self.background_tasks.start_task(
task_id=task_id, task_id=task_id,
coro=self.interface._run_twitch(task_id, params, FakeMsg()), coro=self.interface._run_twitch(task_id, params, msg_mock),
task_type="twitch_irc", task_type="twitch_irc",
metadata={'type': 'twitch_irc', 'channel': params.channel, 'chat_id': params.chat_id} metadata={'type': 'twitch_irc', 'channel': params.channel, 'chat_id': params.chat_id}
) )
@@ -104,7 +115,7 @@ class BotApplication:
async def start(self): async def start(self):
"""Запуск бота.""" """Запуск бота."""
await self.initialize() # ← ВОТ ЭТО БЫЛО ПРОПУЩЕНО await self.initialize()
logger.info("Starting polling...") logger.info("Starting polling...")
await self.dispatcher.start_polling(self.bot) await self.dispatcher.start_polling(self.bot)
+5 -12
View File
@@ -3,7 +3,7 @@
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, Optional, Callable, Coroutine, Any from typing import Dict, Coroutine
from core.constants import TaskStatus from core.constants import TaskStatus
@@ -77,21 +77,14 @@ class BackgroundTaskManager:
return True return True
def _on_task_done(self, task_id: str, task: asyncio.Task) -> None: def _on_task_done(self, task_id: str, task: asyncio.Task) -> None:
"""Callback когда задача завершилась.""" if task.cancelled():
try: logger.info(f"Task {task_id} cancelled")
return
exc = task.exception() exc = task.exception()
if exc: if exc:
if isinstance(exc, asyncio.CancelledError):
logger.info(f"Task {task_id} cancelled (normal)")
# Не считаем ошибкой
else:
logger.error(f"Task {task_id} failed: {exc}") logger.error(f"Task {task_id} failed: {exc}")
else: else:
logger.info(f"Task {task_id} completed successfully") logger.info(f"Task {task_id} completed successfully")
except asyncio.CancelledError:
logger.info(f"Task {task_id} was cancelled")
except Exception as e:
logger.error(f"Error in task callback: {e}")
async def cancel_task(self, task_id: str) -> bool: async def cancel_task(self, task_id: str) -> bool:
""" """
@@ -126,7 +119,7 @@ class BackgroundTaskManager:
cancelled = 0 cancelled = 0
async with self._lock: async with self._lock:
for task_id, task in list(self._tasks.items()): for task in list(self._tasks.values()):
if not task.done(): if not task.done():
task.cancel() task.cancel()
try: try:
+16 -3
View File
@@ -4,9 +4,10 @@
import asyncio import asyncio
import os import os
import random
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, List, Dict from typing import Optional, List, Dict, Set
from enum import Enum from enum import Enum
import logging import logging
@@ -119,6 +120,7 @@ class ProxyManager:
self._usage_count: Dict[str, int] = defaultdict(int) self._usage_count: Dict[str, int] = defaultdict(int)
self._cooldown_until: Dict[str, float] = {} self._cooldown_until: Dict[str, float] = {}
self._working_status: Dict[str, bool] = {} self._working_status: Dict[str, bool] = {}
self._used_ids: Set[str] = set()
self._load_proxies() self._load_proxies()
@@ -163,6 +165,9 @@ class ProxyManager:
if proxy_id in self._in_use: if proxy_id in self._in_use:
return False return False
if proxy_id in self._used_ids:
return False
if proxy_id in self._cooldown_until: if proxy_id in self._cooldown_until:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
if loop.time() < self._cooldown_until[proxy_id]: if loop.time() < self._cooldown_until[proxy_id]:
@@ -193,10 +198,11 @@ class ProxyManager:
] ]
if available: if available:
proxy = min(available, key=lambda p: self._usage_count[p.id]) proxy = random.choice(available)
self._in_use[proxy.id] = asyncio.Event() self._in_use[proxy.id] = asyncio.Event()
self._usage_count[proxy.id] += 1 self._usage_count[proxy.id] += 1
self._used_ids.add(proxy.id)
logger.info( logger.info(
f"Proxy acquired: {proxy.id} " f"Proxy acquired: {proxy.id} "
@@ -205,6 +211,11 @@ class ProxyManager:
) )
return proxy return proxy
all_used = len(self._used_ids) >= len(self._proxies)
if all_used:
logger.info("All proxies used once, resetting used list")
self._used_ids.clear()
logger.debug(f"No proxies available (attempt {attempt})") logger.debug(f"No proxies available (attempt {attempt})")
elapsed = loop.time() - start_time elapsed = loop.time() - start_time
@@ -256,11 +267,13 @@ class ProxyManager:
@property @property
def available_count(self) -> int: def available_count(self) -> int:
loop = asyncio.get_running_loop()
now = loop.time()
working = sum(1 for s in self._working_status.values() if s) working = sum(1 for s in self._working_status.values() if s)
in_use = len(self._in_use) in_use = len(self._in_use)
in_cooldown = sum( in_cooldown = sum(
1 for t in self._cooldown_until.values() 1 for t in self._cooldown_until.values()
if asyncio.get_event_loop().time() < t if now < t
) )
return working - in_use - in_cooldown return working - in_use - in_cooldown
-4
View File
@@ -1,9 +1,5 @@
"""Utility helper functions.""" """Utility helper functions."""
from typing import Tuple
from config.settings import settings
def parse_range(value: str, default_min: int = 5) -> tuple: def parse_range(value: str, default_min: int = 5) -> tuple:
"""Парсит строку диапазона 'мин-макс' или одиночное число.""" """Парсит строку диапазона 'мин-макс' или одиночное число."""
# Очищаем от кавычек и пробелов # Очищаем от кавычек и пробелов