diff --git a/auth/manager.py b/auth/manager.py index 94a56b2..915e01d 100644 --- a/auth/manager.py +++ b/auth/manager.py @@ -1,8 +1,10 @@ import asyncio import hashlib +import json import secrets import logging from datetime import datetime, timedelta +from pathlib import Path from typing import Dict, Optional from config.settings import settings @@ -10,6 +12,7 @@ from config.settings import settings logger = logging.getLogger(__name__) ADMIN_PASSWORD = settings.ADMIN_PASSWORD +_SESSIONS_FILE = Path("data/sessions.json") class AuthManager: @@ -22,6 +25,7 @@ class AuthManager: self._max_attempts = 5 self._lockout_duration = 300 self._session_timeout = timedelta(minutes=session_timeout_minutes) + self._restore_sessions() @staticmethod def _hash_password(password: str, salt: str = None) -> tuple: @@ -53,14 +57,37 @@ class AuthManager: self._sessions[user_id] = datetime.now() self._roles[user_id] = "admin" if is_admin else "user" self._failed_attempts.pop(user_id, None) + self._save_sessions() logger.info(f"User {user_id} logged in as {self._roles[user_id]}") def logout(self, user_id: int): self._sessions.pop(user_id, None) self._roles.pop(user_id, None) self._failed_attempts.pop(user_id, None) + self._save_sessions() logger.info(f"User {user_id} logged out") + def _save_sessions(self) -> None: + try: + _SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True) + data = {str(uid): role for uid, role in self._roles.items()} + _SESSIONS_FILE.write_text(json.dumps(data), encoding="utf-8") + except Exception as e: + logger.warning(f"Failed to save sessions: {e}") + + def _restore_sessions(self) -> None: + try: + if not _SESSIONS_FILE.exists(): + return + data = json.loads(_SESSIONS_FILE.read_text(encoding="utf-8")) + for uid_str, role in data.items(): + uid = int(uid_str) + self._sessions[uid] = datetime.now() + self._roles[uid] = role + logger.info(f"Restored {len(data)} sessions") + except Exception as e: + logger.warning(f"Failed to restore sessions: {e}") + def is_locked_out(self, user_id: int) -> bool: if user_id not in self._failed_attempts: return False diff --git a/handlers/commands.py b/handlers/commands.py index 465ba9c..3a313a4 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -2295,7 +2295,7 @@ class BotInterface: # Resume < 5 мин: продолжает с N+1 без задержки # Resume > 5 мин: серия этой ссылки отбрасывается, ждём следующий цикл - remaining_clicks = 0 + remaining_clicks = params.remaining_clicks # восстанавливаем из сохранённого состояния series_paused_at: Optional[float] = None SERIES_EXPIRY = 5 * 60 @@ -2372,6 +2372,7 @@ class BotInterface: # Клик завершён — проверяем паузу if params.paused or params.stopped: remaining_clicks = series_size - click_num - 1 + params.remaining_clicks = remaining_clicks if remaining_clicks > 0 and not params.stopped: series_paused_at = datetime.now().timestamp() logger.info(f"⏸️ Paused after click {click_num+1}/{series_size}, {remaining_clicks} saved") diff --git a/main.py b/main.py index b3688fa..5f57ab6 100644 --- a/main.py +++ b/main.py @@ -113,25 +113,25 @@ class BotApplication: async def _restore_tasks(self): """Восстанавливает задачи после перезапуска.""" saved_tasks = await self.storage.load_tasks() - + if not saved_tasks: logger.info("No tasks to restore") return - + restored = 0 - notify_users: dict = {} # user_id -> chat_id + # user_id -> (chat_id, was_paused) + notify_users: dict = {} for task_id, params in saved_tasks.items(): - # Завершённые и остановленные — загружаем в память но не запускаем + # Завершённые и остановленные — только в память, не запускаем if params.completed or params.stopped: await self.interface.task_manager.add_task(task_id, params) continue - # Активные задачи ставим на паузу и перезапускаем - # Если стрим был оффлайн — сохраняем этот статус - params.paused = True + was_paused = params.paused # сохраняем намерение пользователя if params.stream_offline: logger.info(f"Task {task_id}: stream was offline, keeping paused") + await self.interface.task_manager.add_task(task_id, params) if params.task_type == "twitch_irc" and params.chat_id: @@ -156,20 +156,19 @@ class BotApplication: restored += 1 if params.user_id and params.chat_id: - notify_users[params.user_id] = params.chat_id + notify_users[params.user_id] = (params.chat_id, was_paused) - # Уведомляем пользователей о рестарте - for uid, chat_id in notify_users.items(): + # Уведомляем пользователей — разные сообщения для паузы и активных задач + for uid, (chat_id, was_paused) in notify_users.items(): try: - await self.bot.send_message( - chat_id, - "🔄 Бот был перезапущен\n\n" - "Ваши задачи приостановлены.\n" - "Нажмите ▶️ в списке задач для возобновления." - ) + if was_paused: + msg = "🔄 Бот был перезапущен\n\n⏸ Ваша задача остаётся на паузе." + else: + msg = "🔄 Бот был перезапущен\n\n▶️ Ваши задачи продолжают работу автоматически." + await self.bot.send_message(chat_id, msg) except Exception as e: logger.warning(f"Failed to notify user {uid}: {e}") - + logger.info(f"🔄 Restored {restored} tasks") async def start(self): diff --git a/managers/storage.py b/managers/storage.py index fe1506e..1eb47e2 100644 --- a/managers/storage.py +++ b/managers/storage.py @@ -54,6 +54,7 @@ class TaskStorage: "max_ctr": params.max_ctr, "started_at": params.started_at.isoformat() if params.started_at else None, "stream_offline": params.stream_offline, + "remaining_clicks": params.remaining_clicks, # pending_visits и total_planned_visits — runtime, не сохраняем } @@ -88,6 +89,7 @@ class TaskStorage: min_ctr=data.get("min_ctr", 0.8), max_ctr=data.get("max_ctr", 1.0), stream_offline=data.get("stream_offline", False), + remaining_clicks=data.get("remaining_clicks", 0), ) if data.get("started_at"): try: diff --git a/managers/task_manager.py b/managers/task_manager.py index 56f8301..458a03e 100644 --- a/managers/task_manager.py +++ b/managers/task_manager.py @@ -59,6 +59,7 @@ class TaskParams: links_found: int = 0 pending_visits: int = 0 # кликов в очереди (runtime, уменьшается) total_planned_visits: int = 0 # всего запланировано кликов по всем ссылкам (runtime) + remaining_clicks: int = 0 # остаток кликов в текущей серии (сохраняется) chat_id: Optional[int] = None user_id: Optional[int] = None # Telegram user_id, если задача создана пользователем diff --git a/tools/proxy_checker.py b/tools/proxy_checker.py new file mode 100644 index 0000000..ea20ae5 --- /dev/null +++ b/tools/proxy_checker.py @@ -0,0 +1,336 @@ +""" +Утилита проверки прокси. + +- Удаляет дубликаты по ip:port +- Тестирует напрямую (HTTP и SOCKS5) +- Тестирует через локальный SOCKS5→HTTP туннель +- Сохраняет результат обратно в файл + +Использование: + python tools/proxy_checker.py + python tools/proxy_checker.py --file proxies.txt --concurrency 10 + python tools/proxy_checker.py --no-save # только показать, не писать файл +""" + +import asyncio +import sys +import time +import argparse +from pathlib import Path +from dataclasses import dataclass, field +from typing import Optional + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from managers.proxy_manager import Proxy, ProxyType + +TEST_URL = "https://www.google.com/generate_204" +TIMEOUT = 10.0 + + +@dataclass +class CheckResult: + proxy: Proxy + direct_ok: bool = False + direct_ms: Optional[float] = None + tunnel_ok: bool = False + tunnel_ms: Optional[float] = None + error: Optional[str] = None + + @property + def ok(self) -> bool: + return self.direct_ok or self.tunnel_ok + + def summary(self) -> str: + parts = [] + if self.direct_ok: + parts.append(f"direct {self.direct_ms:.0f}ms") + if self.tunnel_ok: + parts.append(f"tunnel {self.tunnel_ms:.0f}ms") + if not parts: + return f"FAIL {self.error or ''}" + return "OK " + " | ".join(parts) + + +async def _check_direct(proxy: Proxy, timeout: float) -> tuple[bool, Optional[float]]: + """Прямая проверка через requests в thread pool.""" + import asyncio + loop = asyncio.get_running_loop() + + def _sync(): + import requests + t0 = time.perf_counter() + scheme = proxy.proxy_type.value + if proxy.login and proxy.password: + url = f"{scheme}://{proxy.login}:{proxy.password}@{proxy.ip}:{proxy.port}" + else: + url = f"{scheme}://{proxy.ip}:{proxy.port}" + proxies = {"http": url, "https": url} + try: + r = requests.get(TEST_URL, proxies=proxies, timeout=timeout) + ms = (time.perf_counter() - t0) * 1000 + return r.status_code in (200, 204), ms + except Exception: + return False, None + + return await loop.run_in_executor(None, _sync) + + +async def _check_tunnel(proxy: Proxy, timeout: float) -> tuple[bool, Optional[float]]: + """Проверка через SOCKS5→HTTP туннель (только для SOCKS5/SOCKS4).""" + if proxy.proxy_type not in (ProxyType.SOCKS5, ProxyType.SOCKS4): + return False, None + + from services.socks5_to_http_proxy import Socks5ToHttpProxy + import aiohttp + + tunnel = Socks5ToHttpProxy( + socks5_host=proxy.ip, + socks5_port=int(proxy.port), + username=proxy.login, + password=proxy.password, + ) + try: + await asyncio.wait_for(tunnel.start(), timeout=5) + t0 = time.perf_counter() + ok = await tunnel.check_connection(test_url=TEST_URL, timeout=timeout) + ms = (time.perf_counter() - t0) * 1000 if ok else None + return ok, ms + except Exception: + return False, None + finally: + try: + await asyncio.wait_for(tunnel.stop(), timeout=3) + except Exception: + pass + + +async def _check_direct_as_http(proxy: Proxy, timeout: float) -> tuple[bool, Optional[float]]: + """Принудительная проверка как HTTP прокси (игнорирует настроенный тип).""" + loop = asyncio.get_running_loop() + + def _sync(): + import requests + t0 = time.perf_counter() + if proxy.login and proxy.password: + url = f"http://{proxy.login}:{proxy.password}@{proxy.ip}:{proxy.port}" + else: + url = f"http://{proxy.ip}:{proxy.port}" + proxies = {"http": url, "https": url} + try: + r = requests.get(TEST_URL, proxies=proxies, timeout=timeout) + ms = (time.perf_counter() - t0) * 1000 + return r.status_code in (200, 204), ms + except Exception: + return False, None + + return await loop.run_in_executor(None, _sync) + + +async def check_proxy(proxy: Proxy, timeout: float) -> CheckResult: + result = CheckResult(proxy=proxy) + try: + direct_ok, direct_ms = await asyncio.wait_for( + _check_direct(proxy, timeout), timeout=timeout + 2 + ) + result.direct_ok = direct_ok + result.direct_ms = direct_ms + + if proxy.proxy_type in (ProxyType.SOCKS5, ProxyType.SOCKS4): + tunnel_ok, tunnel_ms = await asyncio.wait_for( + _check_tunnel(proxy, timeout), timeout=timeout + 5 + ) + result.tunnel_ok = tunnel_ok + result.tunnel_ms = tunnel_ms + + except Exception as e: + result.error = str(e)[:60] + return result + + +async def retry_failed_as_http(results: list[CheckResult], timeout: float, sem: asyncio.Semaphore) -> dict[str, CheckResult]: + """ + Повторно проверяет упавшие прокси принудительно как HTTP. + Возвращает dict[ip:port -> CheckResult] только для тех что теперь прошли. + """ + failed = [r for r in results if not r.ok] + if not failed: + return {} + + recovered: dict[str, CheckResult] = {} + + async def _retry(r: CheckResult): + async with sem: + try: + ok, ms = await asyncio.wait_for( + _check_direct_as_http(r.proxy, timeout), timeout=timeout + 2 + ) + except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError, Exception): + ok, ms = False, None + if ok: + key = f"{r.proxy.ip}:{r.proxy.port}" + r.direct_ok = True + r.direct_ms = ms + r.error = None + r.proxy.proxy_type = ProxyType.HTTP + recovered[key] = r + + await asyncio.gather(*[_retry(r) for r in failed], return_exceptions=True) + return recovered + + +def _load_proxies(file_path: str) -> tuple[list[Proxy], list[str]]: + """Загружает прокси из файла. Возвращает (список прокси, сырые строки).""" + raw_lines = Path(file_path).read_text(encoding="utf-8").splitlines() + proxies = [] + seen = set() # ip:port для дедупликации + + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("!"): + continue + p = Proxy.from_line(stripped) + if not p: + continue + key = f"{p.ip}:{p.port}" + if key in seen: + continue + seen.add(key) + proxies.append(p) + + return proxies, raw_lines + + +def _save_results(file_path: str, results: list[CheckResult], raw_lines: list[str]): + """Перезаписывает файл: рабочие без '!', нерабочие с '!'. + Использует актуальный тип прокси из results (может быть изменён retry-фазой). + """ + # ip:port → актуальный объект Proxy с возможно обновлённым типом + proxy_map: dict[str, Proxy] = { + f"{r.proxy.ip}:{r.proxy.port}": r.proxy for r in results + } + working = {f"{r.proxy.ip}:{r.proxy.port}" for r in results if r.ok} + + new_lines = [] + seen_keys: set[str] = set() # для удаления дубликатов + + for line in raw_lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + new_lines.append(line) + continue + + clean = stripped.lstrip("!") + p = Proxy.from_line(clean) + if not p: + new_lines.append(line) + continue + + key = f"{p.ip}:{p.port}" + if key in seen_keys: + continue # дубликат — пропускаем + seen_keys.add(key) + + actual = proxy_map.get(key, p) + scheme = actual.proxy_type.value + if actual.login and actual.password: + formatted = f"{scheme}://{actual.ip}:{actual.port}:{actual.login}:{actual.password}" + else: + formatted = f"{scheme}://{actual.ip}:{actual.port}" + + if key in working: + new_lines.append(formatted) + else: + new_lines.append(f"!{formatted}") + + Path(file_path).write_text("\n".join(new_lines) + "\n", encoding="utf-8") + + +async def run_checker(file_path: str, concurrency: int, timeout: float, save: bool, retry_http: bool): + proxies, raw_lines = _load_proxies(file_path) + + total_raw = sum( + 1 for l in raw_lines + if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("!") + ) + duplicates = total_raw - len(proxies) + + print(f"\n{'='*60}") + print(f" Файл: {file_path}") + print(f" Загружено: {total_raw} | Дубликатов: {duplicates} | К проверке: {len(proxies)}") + print(f" Параллельность: {concurrency} | Таймаут: {timeout}s") + if retry_http: + print(f" Повтор упавших через HTTP: ВКЛ") + print(f"{'='*60}\n") + + sem = asyncio.Semaphore(concurrency) + results: list[CheckResult] = [] + done = 0 + + async def _run(proxy: Proxy): + nonlocal done + async with sem: + r = await check_proxy(proxy, timeout) + results.append(r) + done += 1 + status = "✅" if r.ok else "❌" + print(f" [{done:>3}/{len(proxies)}] {status} {proxy.id:<35} {r.summary()}") + + await asyncio.gather(*[_run(p) for p in proxies]) + + # Фаза 2: повторная проверка упавших как HTTP + recovered_count = 0 + if retry_http: + failed_count = sum(1 for r in results if not r.ok) + if failed_count: + print(f"\n ── Повтор {failed_count} упавших как HTTP прямую ──\n") + recovered = await retry_failed_as_http(results, timeout, sem) + recovered_count = len(recovered) + for key, r in recovered.items(): + print(f" [retry] ✅ {r.proxy.ip}:{r.proxy.port} → HTTP {r.direct_ms:.0f}ms") + + ok_count = sum(1 for r in results if r.ok) + fail_count = len(results) - ok_count + direct_ok = sum(1 for r in results if r.direct_ok) + tunnel_ok = sum(1 for r in results if r.tunnel_ok) + + print(f"\n{'='*60}") + print(f" ИТОГО") + print(f"{'='*60}") + print(f" Рабочих : {ok_count} / {len(proxies)}") + print(f" Нерабочих : {fail_count}") + print(f" Direct OK : {direct_ok}") + print(f" Tunnel OK : {tunnel_ok}") + if recovered_count: + print(f" Спасено HTTP: {recovered_count}") + if duplicates: + print(f" Дубликатов : {duplicates} (удалены)") + + if save: + _save_results(file_path, results, raw_lines) + print(f"\n Файл обновлён: {file_path}") + print(f" Нерабочие помечены '!'") + + print(f"{'='*60}\n") + + +def main(): + parser = argparse.ArgumentParser(description="Проверка прокси") + parser.add_argument("--file", default="proxies.txt", help="Файл с прокси") + parser.add_argument("--concurrency", type=int, default=5, help="Параллельность (по умолчанию: 5)") + parser.add_argument("--timeout", type=float, default=10.0, help="Таймаут секунд (по умолчанию: 10)") + parser.add_argument("--no-save", action="store_true", help="Не перезаписывать файл") + parser.add_argument("--retry-http", action="store_true", help="Повторить упавшие как HTTP напрямую") + args = parser.parse_args() + + asyncio.run(run_checker( + file_path=args.file, + concurrency=args.concurrency, + timeout=args.timeout, + save=not args.no_save, + retry_http=args.retry_http, + )) + + +if __name__ == "__main__": + main()