init auth and main function

This commit is contained in:
Yuriy Yuriev
2026-05-14 16:15:58 +07:00
commit 27c53e897e
47 changed files with 5750 additions and 0 deletions
View File
+170
View File
@@ -0,0 +1,170 @@
"""Background task manager for async task tracking."""
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Dict, Optional, Callable, Coroutine, Any
from core.constants import TaskStatus
logger = logging.getLogger(__name__)
@dataclass
class TaskInfo:
"""Information about a running task."""
task_id: str
task_type: str
coroutine: Coroutine = None
status: TaskStatus = TaskStatus.RUNNING
created_at: float = field(default_factory=lambda: asyncio.get_running_loop().time())
metadata: dict = field(default_factory=dict)
class BackgroundTaskManager:
"""
Manages background async tasks with tracking and cancellation support.
Features:
- Start and track async tasks
- Cancel individual or all tasks
- Get task status and metadata
- Prevent duplicate tasks
"""
def __init__(self):
self._tasks: Dict[str, asyncio.Task] = {}
self._task_info: Dict[str, TaskInfo] = {}
self._lock = asyncio.Lock()
async def start_task(
self,
task_id: str,
coro: Coroutine,
task_type: str = "unknown",
metadata: dict = None
) -> bool:
"""
Start a background task.
Args:
task_id: Unique task identifier
coro: Coroutine to run
task_type: Type of task
metadata: Additional task metadata
Returns:
True if started successfully, False if already running
"""
async with self._lock:
if task_id in self._tasks and not self._tasks[task_id].done():
logger.warning(f"Task {task_id} is already running")
return False
task = asyncio.create_task(coro)
self._tasks[task_id] = task
self._task_info[task_id] = TaskInfo(
task_id=task_id,
task_type=task_type,
coroutine=coro,
metadata=metadata or {}
)
task.add_done_callback(lambda t, tid=task_id: self._on_task_done(tid, t))
logger.info(f"Started background task: {task_id} ({task_type})")
return True
def _on_task_done(self, task_id: str, task: asyncio.Task) -> None:
"""Callback когда задача завершилась."""
try:
exc = task.exception()
if exc:
if isinstance(exc, asyncio.CancelledError):
logger.info(f"Task {task_id} cancelled (normal)")
# Не считаем ошибкой
else:
logger.error(f"Task {task_id} failed: {exc}")
else:
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:
"""
Cancel a specific task.
Args:
task_id: Task identifier to cancel
Returns:
True if task was cancelled, False if not found
"""
async with self._lock:
task = self._tasks.get(task_id)
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info(f"Task cancelled: {task_id}")
return True
return False
async def cancel_all(self) -> int:
"""
Cancel all running tasks.
Returns:
Number of tasks cancelled
"""
cancelled = 0
async with self._lock:
for task_id, task in list(self._tasks.items()):
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
cancelled += 1
self._tasks.clear()
self._task_info.clear()
logger.info(f"All tasks cancelled: {cancelled}")
return cancelled
def get_active_tasks(self) -> Dict[str, dict]:
"""
Get all active tasks with their metadata.
Returns:
Dictionary of active tasks
"""
active = {}
for task_id, task in self._tasks.items():
if not task.done():
info = self._task_info.get(task_id)
active[task_id] = {
"info": info.metadata if info else {},
"status": TaskStatus.RUNNING.value,
"type": info.task_type if info else "unknown",
"created_at": info.created_at if info else 0
}
return active
@property
def active_count(self) -> int:
"""Number of currently active tasks."""
return sum(1 for t in self._tasks.values() if not t.done())
def has_active_tasks(self) -> bool:
"""Check if there are any active tasks."""
return self.active_count > 0
+276
View File
@@ -0,0 +1,276 @@
"""
Менеджер прокси с поддержкой SOCKS5 и HTTP.
"""
import asyncio
import os
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional, List, Dict
from enum import Enum
import logging
from config.settings import settings
from core.exceptions import ProxyTimeoutError, NoProxiesAvailableError
logger = logging.getLogger(__name__)
class ProxyType(str, Enum):
"""Типы прокси."""
SOCKS5 = "socks5"
SOCKS4 = "socks4"
HTTP = "http"
HTTPS = "https"
@dataclass
class Proxy:
"""Прокси с поддержкой разных типов."""
ip: str
port: str
proxy_type: ProxyType = ProxyType.SOCKS5
login: Optional[str] = None
password: Optional[str] = None
@property
def id(self) -> str:
return f"{self.proxy_type.value}://{self.ip}:{self.port}"
@property
def server(self) -> str:
"""URL сервера для Camoufox/Playwright."""
protocol = "socks5" if self.proxy_type in [ProxyType.SOCKS5, ProxyType.SOCKS4] else "http"
return f"{protocol}://{self.ip}:{self.port}"
@property
def proxy_config(self) -> dict:
"""Конфигурация для браузера."""
config = {
'server': self.server,
}
if self.login and self.password:
config.update({
'username': self.login,
'password': self.password
})
return config
@classmethod
def from_line(cls, line: str, default_type: ProxyType = ProxyType.SOCKS5) -> Optional['Proxy']:
"""
Парсит строку прокси.
Форматы:
- ip:port (SOCKS5 по умолчанию)
- ip:port:login:password
- socks5://ip:port:login:password
- http://ip:port:login:password
"""
line = line.strip()
# Определяем тип прокси по префиксу
proxy_type = default_type
if "://" in line:
protocol, rest = line.split("://", 1)
protocol = protocol.lower()
if protocol in ["socks5", "socks"]:
proxy_type = ProxyType.SOCKS5
elif protocol == "socks4":
proxy_type = ProxyType.SOCKS4
elif protocol == "http":
proxy_type = ProxyType.HTTP
elif protocol == "https":
proxy_type = ProxyType.HTTPS
line = rest
# Парсим части
parts = line.split(":")
if len(parts) == 2:
return cls(ip=parts[0], port=parts[1], proxy_type=proxy_type)
elif len(parts) == 4:
return cls(
ip=parts[0],
port=parts[1],
login=parts[2],
password=parts[3],
proxy_type=proxy_type
)
return None
class ProxyManager:
"""
Менеджер прокси с поддержкой SOCKS5 и HTTP.
"""
def __init__(self, proxy_file: Optional[str] = None):
self.proxy_file = proxy_file or settings.PROXY_FILE
self.cooldown_time = settings.PROXY_COOLDOWN_TIME
self.max_usage = settings.PROXY_MAX_USAGE_BEFORE_COOLDOWN
self._proxies: List[Proxy] = []
self._lock = asyncio.Lock()
self._in_use: Dict[str, asyncio.Event] = {}
self._usage_count: Dict[str, int] = defaultdict(int)
self._cooldown_until: Dict[str, float] = {}
self._working_status: Dict[str, bool] = {}
self._load_proxies()
def _load_proxies(self) -> None:
"""Загружает прокси из файла."""
if not os.path.exists(self.proxy_file):
logger.warning(f"Proxy file not found: {self.proxy_file}")
return
try:
with open(self.proxy_file, 'r', encoding='utf-8') as f:
lines = [
line.strip()
for line in f
if line.strip() and not line.startswith('#')
]
loaded = []
for line in lines:
proxy = Proxy.from_line(line, default_type=ProxyType.SOCKS5)
if proxy:
self._proxies.append(proxy)
self._working_status[proxy.id] = True
loaded.append(proxy)
logger.info(f"Loaded {len(loaded)} proxies from {self.proxy_file}")
# Статистика по типам
type_counts = defaultdict(int)
for p in loaded:
type_counts[p.proxy_type.value] += 1
for ptype, count in type_counts.items():
logger.info(f" {ptype}: {count}")
except Exception as e:
logger.error(f"Failed to load proxies: {e}", exc_info=True)
def _is_available(self, proxy: Proxy) -> bool:
"""Проверка доступности прокси."""
proxy_id = proxy.id
if proxy_id in self._in_use:
return False
if proxy_id in self._cooldown_until:
loop = asyncio.get_running_loop()
if loop.time() < self._cooldown_until[proxy_id]:
return False
del self._cooldown_until[proxy_id]
if self._usage_count[proxy_id] >= self.max_usage:
return False
return self._working_status.get(proxy_id, False)
async def acquire_proxy(self, timeout: float = 60) -> Optional[Proxy]:
"""Получает доступный прокси."""
if not self._proxies:
return None
loop = asyncio.get_running_loop()
start_time = loop.time()
attempt = 0
while True:
attempt += 1
async with self._lock:
available = [
p for p in self._proxies
if self._is_available(p)
]
if available:
proxy = min(available, key=lambda p: self._usage_count[p.id])
self._in_use[proxy.id] = asyncio.Event()
self._usage_count[proxy.id] += 1
logger.info(
f"Proxy acquired: {proxy.id} "
f"(type: {proxy.proxy_type.value}, "
f"used: {self._usage_count[proxy.id]}x)"
)
return proxy
logger.debug(f"No proxies available (attempt {attempt})")
elapsed = loop.time() - start_time
if elapsed >= timeout:
logger.warning(f"Proxy acquire timeout ({timeout}s)")
return None
await asyncio.sleep(1)
async def release_proxy(self, proxy: Proxy, success: bool = True) -> None:
"""Освобождает прокси."""
if not proxy:
return
proxy_id = proxy.id
async with self._lock:
if proxy_id in self._in_use:
del self._in_use[proxy_id]
if self._usage_count[proxy_id] >= self.max_usage:
loop = asyncio.get_running_loop()
self._cooldown_until[proxy_id] = (
loop.time() + self.cooldown_time
)
self._usage_count[proxy_id] = 0
if not success:
self._working_status[proxy_id] = False
logger.debug(f"Proxy released: {proxy_id}")
def get_stats(self) -> dict:
"""Статистика прокси."""
loop = asyncio.get_running_loop()
now = loop.time()
return {
"total": len(self._proxies),
"working": sum(1 for s in self._working_status.values() if s),
"in_use": len(self._in_use),
"available": self.available_count,
"in_cooldown": sum(1 for t in self._cooldown_until.values() if now < t),
}
@property
def count(self) -> int:
return len(self._proxies)
@property
def available_count(self) -> int:
working = sum(1 for s in self._working_status.values() if s)
in_use = len(self._in_use)
in_cooldown = sum(
1 for t in self._cooldown_until.values()
if asyncio.get_event_loop().time() < t
)
return working - in_use - in_cooldown
def has_proxies(self) -> bool:
return len(self._proxies) > 0
def get_socks5_proxies(self) -> List[Proxy]:
"""Список SOCKS5 прокси."""
return [p for p in self._proxies if p.proxy_type == ProxyType.SOCKS5]
def get_http_proxies(self) -> List[Proxy]:
"""Список HTTP прокси."""
return [p for p in self._proxies if p.proxy_type in [ProxyType.HTTP, ProxyType.HTTPS]]
+177
View File
@@ -0,0 +1,177 @@
"""
Сохранение и загрузка задач между перезапусками.
"""
import json
import asyncio
import logging
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional
from managers.task_manager import TaskParams
logger = logging.getLogger(__name__)
class TaskStorage:
"""Хранилище задач в JSON файле."""
def __init__(self, file_path: str = "data/tasks.json"):
self.file_path = Path(file_path)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
def _params_to_dict(self, params: TaskParams) -> dict:
"""Конвертирует TaskParams в словарь."""
return {
"url": params.url,
"task_type": params.task_type,
"min_delay": params.min_delay,
"max_delay": params.max_delay,
"min_reading": params.min_reading,
"max_reading": params.max_reading,
"max_visits": params.max_visits,
"current_visit": params.current_visit,
"channel": params.channel,
"target_username": params.target_username,
"visits_per_link": params.visits_per_link,
"monitor_minutes": params.monitor_minutes,
"allowed_domains": params.allowed_domains,
"paused": params.paused,
"completed": params.completed,
"stopped": params.stopped,
"total_visits": params.total_visits,
"successful_visits": params.successful_visits,
"links_found": params.links_found,
"chat_id": params.chat_id,
"started_at": params.started_at.isoformat() if params.started_at else None,
}
def _dict_to_params(self, data: dict) -> TaskParams:
"""Конвертирует словарь в TaskParams."""
params = TaskParams(
url=data.get("url", ""),
task_type=data.get("task_type", "visit"),
min_delay=data.get("min_delay", 10),
max_delay=data.get("max_delay", 30),
min_reading=data.get("min_reading", 5),
max_reading=data.get("max_reading", 15),
max_visits=data.get("max_visits"),
current_visit=data.get("current_visit", 0),
channel=data.get("channel", ""),
target_username=data.get("target_username", "*"),
visits_per_link=data.get("visits_per_link", 5),
monitor_minutes=data.get("monitor_minutes", 10),
allowed_domains=data.get("allowed_domains"),
paused=data.get("paused", False),
completed=data.get("completed", False),
stopped=data.get("stopped", False),
total_visits=data.get("total_visits", 0),
successful_visits=data.get("successful_visits", 0),
links_found=data.get("links_found", 0),
chat_id=data.get("chat_id"),
)
if data.get("started_at"):
try:
params.started_at = datetime.fromisoformat(data["started_at"])
except (ValueError, TypeError) as e:
logger.warning(f"Failed to parse started_at: {e}")
return params
async def save_tasks(self, tasks: Dict[str, TaskParams]) -> None:
"""Сохраняет все задачи в файл."""
async with self._lock:
try:
data = {}
for task_id, params in tasks.items():
data[task_id] = self._params_to_dict(params)
with open(self.file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"💾 Saved {len(data)} tasks to {self.file_path}")
except Exception as e:
logger.error(f"Save error: {e}")
async def load_tasks(self) -> Dict[str, TaskParams]:
"""Загружает задачи из файла."""
if not self.file_path.exists():
logger.info("No saved tasks found")
return {}
try:
with open(self.file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
tasks = {}
for task_id, params_data in data.items():
tasks[task_id] = self._dict_to_params(params_data)
logger.info(f"📂 Loaded {len(tasks)} tasks from {self.file_path}")
return tasks
except Exception as e:
logger.error(f"Load error: {e}")
return {}
async def save_task(self, task_id: str, params: TaskParams) -> None:
"""Сохраняет одну задачу (добавляет к существующим)."""
tasks = await self.load_tasks()
tasks[task_id] = params
await self.save_tasks(tasks)
async def delete_task(self, task_id: str) -> None:
"""Удаляет задачу из файла."""
tasks = await self.load_tasks()
if task_id in tasks:
del tasks[task_id]
await self.save_tasks(tasks)
class ChatStorage:
"""Хранилище привязки стримеров к чатам."""
def __init__(self, file_path: str = "data/chats.json"):
self.file_path = Path(file_path)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
async def get_chat_streamers(self, chat_id: int) -> list:
"""Получает список стримеров для чата."""
data = await self._load()
return data.get(str(chat_id), [])
async def add_streamer(self, chat_id: int, channel: str) -> None:
"""Добавляет стримера в чат."""
data = await self._load()
key = str(chat_id)
if key not in data:
data[key] = []
if channel not in data[key]:
data[key].append(channel)
await self._save(data)
async def remove_streamer(self, chat_id: int, channel: str) -> None:
"""Удаляет стримера из чата."""
data = await self._load()
key = str(chat_id)
if key in data and channel in data[key]:
data[key].remove(channel)
await self._save(data)
async def _load(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 chats: {e}")
return {}
async def _save(self, data: dict) -> None:
async with self._lock:
with open(self.file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
+303
View File
@@ -0,0 +1,303 @@
"""
Менеджер задач по стримерам.
Простой список с возможностью изменять параметры на лету.
"""
import asyncio
from dataclasses import dataclass, field
from typing import Optional, List, Dict
from datetime import datetime
import random
import logging
logger = logging.getLogger(__name__)
@dataclass
class TaskParams:
"""Динамические параметры задачи."""
url: str = ""
task_type: str = "visit"
# Задержки
min_delay: int = 10
max_delay: int = 30
min_reading: int = 5
max_reading: int = 15
# Лимиты
max_visits: Optional[int] = None
current_visit: int = 0
# Twitch параметры
channel: str = ""
target_username: str = ""
visits_per_link: int = 5
monitor_minutes: int = 10
allowed_domains: Optional[List[str]] = None
# Состояние
paused: bool = False
skip_next: bool = False
force_delay: Optional[int] = None
force_reading: Optional[int] = None
# Статистика
started_at: Optional[datetime] = None
total_visits: int = 0
successful_visits: int = 0
links_found: int = 0
chat_id: Optional[int] = None
completed: bool = False # Задача завершена
stopped: bool = False # Задача остановлена пользователем
@property
def monitor_duration(self) -> int:
return self.monitor_minutes * 60
def update(self, **kwargs):
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def get_delay(self) -> int:
if self.skip_next:
self.skip_next = False
return 0
if self.force_delay:
delay = self.force_delay
self.force_delay = None
return delay
return random.randint(self.min_delay, self.max_delay)
def get_reading_time(self) -> int:
if self.force_reading:
reading = self.force_reading
self.force_reading = None
return reading
return random.randint(self.min_reading, self.max_reading)
def get_status_emoji(self) -> str:
if self.stopped:
return "🛑"
if self.completed:
return ""
if self.paused:
return "⏸️"
return "🔄"
def get_status_text(self) -> str:
if self.stopped:
return "🛑 Остановлена"
if self.completed:
return "✅ Завершена"
if self.paused:
return "⏸️ Пауза"
return "🔄 Активна"
def get_stats_text(self) -> str:
"""Текст статистики."""
if self.task_type == "twitch_irc":
status = self.get_status_text()
duration = ""
if self.started_at and (self.completed or self.stopped):
delta = datetime.now() - self.started_at
duration = f"├ ⏱️ Длилась: {delta.seconds // 60} мин\n"
# Домены
domains_text = ""
if self.allowed_domains:
domains_text = f"├ 🌐 Домены: {', '.join(self.allowed_domains)}\n"
else:
domains_text = "├ 🌐 Домены: все\n"
return (
f"📺 **{self.channel}**\n"
f"├ Статус: {status}\n"
f"├ 🔗 Ссылок найдено: {self.links_found}\n"
f"├ 📊 Всего визитов: {self.total_visits}\n"
f"├ ✅ Успешно: {self.successful_visits}\n"
f"├ ⏱️ Задержка: {self.min_delay}-{self.max_delay}с\n"
f"├ 📖 Чтение: {self.min_reading}-{self.max_reading}с\n"
f"├ 🔗 На ссылку: {self.visits_per_link} визитов\n"
f"├ ⏰ Мониторинг: {self.monitor_minutes} мин\n"
f"{domains_text}"
f"{duration}"
)
else:
return (
f"🌐 `{self.url[:50]}`\n"
f"├ Статус: {self.get_status_text()}\n"
f"├ 📊 Визитов: {self.total_visits}\n"
f"├ ✅ Успешно: {self.successful_visits}\n"
)
class TaskManager:
"""
Менеджер задач.
Хранит список стримеров и позволяет управлять каждым.
"""
def __init__(self):
self._tasks: Dict[str, TaskParams] = {} # task_id -> params
self._lock = asyncio.Lock()
async def add_task(self, task_id: str, params: TaskParams) -> None:
"""Добавляет задачу."""
async with self._lock:
self._tasks[task_id] = params
logger.info(f" Задача: {task_id[:12]} ({params.channel or params.url})")
async def remove_task(self, task_id: str) -> bool:
"""Удаляет задачу."""
async with self._lock:
if task_id in self._tasks:
del self._tasks[task_id]
logger.info(f" Задача удалена: {task_id[:12]}")
return True
return False
async def get_task(self, task_id: str) -> Optional[TaskParams]:
"""Получает задачу."""
async with self._lock:
return self._tasks.get(task_id)
async def update_task(self, task_id: str, **kwargs) -> bool:
"""Обновляет параметры задачи."""
params = await self.get_task(task_id)
if params:
params.update(**kwargs)
return True
return False
async def complete_task(self, task_id: str) -> bool:
"""Пометить задачу как завершенную."""
return await self.update_task(task_id, completed=True, paused=True)
async def stop_task(self, task_id: str) -> bool:
"""Пометить задачу как остановленную."""
return await self.update_task(task_id, stopped=True, paused=True)
async def get_active_tasks(self) -> Dict[str, TaskParams]:
"""Только активные задачи (не завершенные и не остановленные)."""
return {
tid: p for tid, p in self._tasks.items()
if not p.completed and not p.stopped
}
async def get_completed_tasks(self) -> Dict[str, TaskParams]:
"""Завершенные задачи (для просмотра статистики)."""
return {
tid: p for tid, p in self._tasks.items()
if p.completed or p.stopped
}
async def pause_task(self, task_id: str) -> bool:
"""Пауза задачи."""
return await self.update_task(task_id, paused=True)
async def resume_task(self, task_id: str) -> bool:
"""Снять с паузы."""
return await self.update_task(task_id, paused=False)
async def skip_next_task(self, task_id: str) -> bool:
"""Пропустить следующее действие."""
return await self.update_task(task_id, skip_next=True)
async def restart_task(self, task_id: str) -> bool:
"""Сбросить счетчики для перезапуска."""
return await self.update_task(
task_id,
current_visit=0,
total_visits=0,
successful_visits=0,
links_found=0
)
async def get_all_tasks(self) -> Dict[str, TaskParams]:
"""Все задачи."""
async with self._lock:
return dict(self._tasks)
async def get_tasks_by_type(self, task_type: str) -> Dict[str, TaskParams]:
"""Задачи по типу."""
return {
tid: p for tid, p in self._tasks.items()
if p.task_type == task_type
}
async def get_twitch_tasks(self) -> Dict[str, TaskParams]:
"""Только Twitch задачи."""
return await self.get_tasks_by_type("twitch_irc")
async def get_visit_tasks(self) -> Dict[str, TaskParams]:
"""Только задачи посещения."""
return await self.get_tasks_by_type("visit")
async def get_stats(self) -> dict:
"""Общая статистика."""
twitch_tasks = await self.get_twitch_tasks()
visit_tasks = await self.get_visit_tasks()
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),
}
def _get_task_info(self, task_id: str, params: TaskParams) -> str:
"""Информация о задаче для отображения."""
status = params.get_status_emoji()
if params.task_type == "twitch_irc":
return (
f"{status} 📺 **{params.channel}**\n"
f" `{task_id[:12]}...`\n"
f" 👤 @{params.target_username} | "
f"🔗{params.links_found} | "
f"📊{params.total_visits}\n"
)
else:
return (
f"{status} 🌐 `{params.url[:40]}`\n"
f" `{task_id[:12]}...`\n"
f" 📊{params.total_visits} визитов\n"
)
async def format_task_list(self, max_items: int = 15) -> str:
"""Форматирует список задач для сообщения."""
async with self._lock:
if not self._tasks:
return "📊 Нет активных задач"
# Сначала Twitch, потом визиты
twitch = await self.get_twitch_tasks()
visits = await self.get_visit_tasks()
async with self._lock:
total_tasks = len(self._tasks)
text = f"📊 **ЗАДАЧИ: {total_tasks}**\n\n"
if twitch:
text += "📺 **Twitch мониторинг:**\n"
for i, (task_id, params) in enumerate(list(twitch.items())[:5]):
text += self._get_task_info(task_id, params)
if len(twitch) > 5:
text += f" _...и еще {len(twitch) - 5}_\n"
text += "\n"
if visits:
text += "🌐 **Посещение страниц:**\n"
for i, (task_id, params) in enumerate(list(visits.items())[:5]):
text += self._get_task_info(task_id, params)
if len(visits) > 5:
text += f" _...и еще {len(visits) - 5}_\n"
return text