fix: имя роутера в сообщении об ошибке подключения

This commit is contained in:
2026-05-19 11:17:36 +07:00
parent 17e6d7ae08
commit eb8642ebaa
+352 -137
View File
@@ -1,21 +1,31 @@
#!/usr/bin/env python3
"""
MikroTik IP Monitor — читает адреса из /ip firewall address-list,
пингует их через MikroTik API и шлёт уведомления в Telegram.
пингует параллельно, шлёт уведомления в Telegram и MAX (VK Teams).
Возможности:
- Параллельный пинг всех хостов
- Повторные уведомления о даунтайме
- Время даунтайма при восстановлении
- Ежедневный отчёт
- Команды /status, /help через Telegram и MAX
- Веб-дашборд на порту 8080
"""
import os
import time
import logging
import asyncio
from datetime import datetime
import threading
from datetime import datetime, timedelta
from typing import Optional
from http.server import HTTPServer, BaseHTTPRequestHandler
import routeros_api
import httpx
# ──────────────────────────────────────────────
# Настройки из переменных окружения
# Настройки
# ──────────────────────────────────────────────
MIKROTIK_HOST = os.environ["MIKROTIK_HOST"]
MIKROTIK_PORT = int(os.getenv("MIKROTIK_PORT", "8728"))
@@ -26,18 +36,18 @@ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org")
# MAX (VK Teams) — опционально, если не заданы — уведомления не отправляются
MAX_BOT_TOKEN = os.getenv("MAX_BOT_TOKEN", "")
MAX_CHAT_ID = os.getenv("MAX_CHAT_ID", "")
MAX_API_URL = os.getenv("MAX_API_URL", "https://platform-api.max.ru")
# Имя address-list в MikroTik (/ip firewall address-list)
ADDRESS_LIST_NAME = os.environ["ADDRESS_LIST_NAME"]
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60"))
PING_COUNT = int(os.getenv("PING_COUNT", "3"))
PING_TIMEOUT_MS = int(os.getenv("PING_TIMEOUT_MS", "1000"))
FAILURE_THRESHOLD = int(os.getenv("FAILURE_THRESHOLD", "2"))
REPEAT_NOTIFY_MIN = int(os.getenv("REPEAT_NOTIFY_MIN", "30"))
DAILY_REPORT_TIME = os.getenv("DAILY_REPORT_TIME", "09:00")
WEB_PORT = int(os.getenv("WEB_PORT", "8080"))
# ──────────────────────────────────────────────
# Логирование
@@ -49,9 +59,20 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
# ──────────────────────────────────────────────
# Глобальное состояние
# ──────────────────────────────────────────────
hosts_state: dict[str, dict] = {}
router_identity: str = MIKROTIK_HOST
last_check_time: Optional[datetime] = None
daily_report_sent_date: Optional[str] = None
_tg_bot_username: str = ""
tg_last_update_id: int = 0
max_last_marker: int = 0
# ──────────────────────────────────────────────
# Telegram
# Уведомления
# ──────────────────────────────────────────────
async def send_telegram(message: str) -> None:
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
@@ -61,57 +82,41 @@ async def send_telegram(message: str) -> None:
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(url, data=payload)
log.info("Telegram response %s: %s", resp.status_code, resp.text)
resp.raise_for_status()
log.info("Telegram: сообщение отправлено")
log.info("Telegram: отправлено")
except httpx.HTTPStatusError as exc:
log.error("Telegram: ошибка %s%s", exc.response.status_code, exc.response.text)
log.error("Telegram: %s%s", exc.response.status_code, exc.response.text)
except Exception as exc:
log.error("Telegram: ошибка отправки — %s", exc)
log.error("Telegram: %s", exc)
async def send_max(message: str) -> None:
"""Отправляет уведомление в MAX (VK Teams)."""
if not MAX_BOT_TOKEN or not MAX_CHAT_ID:
return
import re
# chat_id и user_id — query-параметры, не тело запроса
params = {"chat_id": MAX_CHAT_ID}
headers = {
"Authorization": MAX_BOT_TOKEN,
"Content-Type": "application/json",
}
# MAX поддерживает HTML — оставляем форматирование
headers = {"Authorization": MAX_BOT_TOKEN, "Content-Type": "application/json"}
payload = {"text": message, "format": "html"}
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(
f"{MAX_API_URL}/messages",
headers=headers,
params=params,
json=payload,
headers=headers, params={"chat_id": MAX_CHAT_ID}, json=payload,
)
log.info("MAX response %s: %s", resp.status_code, resp.text)
resp.raise_for_status()
log.info("MAX: сообщение отправлено")
log.info("MAX: отправлено")
except Exception as exc:
log.error("MAX: ошибка отправки — %s", exc)
log.error("MAX: %s", exc)
def notify(message: str) -> None:
async def _send_all():
await asyncio.gather(
send_telegram(message),
send_max(message),
)
asyncio.run(_send_all())
async def _all():
await asyncio.gather(send_telegram(message), send_max(message))
asyncio.run(_all())
# ──────────────────────────────────────────────
# MikroTik: получить список IP из address-list
# MikroTik утилиты
# ──────────────────────────────────────────────
def decode(val) -> str:
"""Декодирует bytes или str. Пробует UTF-8, затем Windows-1251."""
if isinstance(val, bytes):
try:
return val.decode("utf-8")
@@ -121,18 +126,9 @@ def decode(val) -> str:
def fetch_address_list(api) -> list[tuple[str, str]]:
"""
Читает /ip firewall address-list и возвращает список (ip, comment)
из записей с именем ADDRESS_LIST_NAME (пропускает отключённые).
"""
resource = api.get_binary_resource("/ip/firewall/address-list")
all_entries = resource.get()
ip_list = []
for entry in all_entries:
# Ключи строковые, значения — bytes
entry_list = decode(entry.get("list", b""))
if entry_list != ADDRESS_LIST_NAME:
for entry in api.get_binary_resource("/ip/firewall/address-list").get():
if decode(entry.get("list", b"")) != ADDRESS_LIST_NAME:
continue
if decode(entry.get("disabled", b"false")) == "true":
continue
@@ -142,31 +138,18 @@ def fetch_address_list(api) -> list[tuple[str, str]]:
comment = decode(entry.get("comment", b"")).strip()
if address:
ip_list.append((address, comment))
return ip_list
# ──────────────────────────────────────────────
# MikroTik: пинг через API
# ──────────────────────────────────────────────
def mikrotik_ping(api, address: str) -> Optional[float]:
"""
Возвращает avg-rtt (мс) или None если хост недоступен.
RouterOS API: /ping — это команда, вызывается через get_binary_resource.
"""
def fetch_identity(api) -> str:
try:
binary_resource = api.get_binary_resource("/")
results = binary_resource.call(
"ping",
{
"address": address.encode(),
"count": str(PING_COUNT).encode(),
"interval": b"0.2",
},
)
result = api.get_binary_resource("/system/identity").get()
return decode(result[0].get("name", MIKROTIK_HOST.encode())) if result else MIKROTIK_HOST
except Exception:
return MIKROTIK_HOST
def parse_rtt(val) -> Optional[float]:
"""Парсит '3ms674us', '2ms', '500us' → миллисекунды (float)."""
def parse_rtt(val) -> Optional[float]:
if isinstance(val, bytes):
val = val.decode()
val = val.strip()
@@ -192,55 +175,260 @@ def mikrotik_ping(api, address: str) -> Optional[float]:
total = ms + us / 1000.0
return total if total > 0 else None
rtts = []
for row in results:
# Берём avg-rtt из последней строки (она накопительная)
rtt = parse_rtt(row.get("avg-rtt", ""))
if rtt is not None:
rtts.append(rtt)
if rtts:
return rtts[-1] # последняя строка содержит финальный avg
return None
except Exception as exc:
log.warning("Ошибка пинга %s: %s", address, exc)
return None
# ──────────────────────────────────────────────
# MikroTik: получить system identity
# ──────────────────────────────────────────────
def fetch_identity(api) -> str:
def mikrotik_ping(api, address: str) -> Optional[float]:
try:
resource = api.get_binary_resource("/system/identity")
result = resource.get()
if result:
return decode(result[0].get("name", MIKROTIK_HOST.encode()))
return MIKROTIK_HOST
except Exception:
return MIKROTIK_HOST
results = api.get_binary_resource("/").call(
"ping",
{"address": address.encode(), "count": str(PING_COUNT).encode(), "interval": b"0.2"},
)
rtts = [r for r in (parse_rtt(row.get("avg-rtt", "")) for row in results) if r is not None]
return rtts[-1] if rtts else None
except Exception as exc:
log.warning("Пинг %s: %s", address, exc)
return None
# ──────────────────────────────────────────────
# Параллельный пинг
# ──────────────────────────────────────────────
async def ping_all_async(api, ip_list: list[tuple[str, str]]) -> dict[str, Optional[float]]:
loop = asyncio.get_event_loop()
tasks = {ip: loop.run_in_executor(None, mikrotik_ping, api, ip) for ip, _ in ip_list}
return {ip: await task for ip, task in tasks.items()}
# ──────────────────────────────────────────────
# Форматирование
# ──────────────────────────────────────────────
def fmt_duration(td: timedelta) -> str:
total = int(td.total_seconds())
if total < 60:
return f"{total} сек"
elif total < 3600:
return f"{total // 60} мин"
else:
h, m = divmod(total // 60, 60)
return f"{h} ч {m} мин" if m else f"{h} ч"
def fmt_label(ip: str) -> str:
comment = hosts_state.get(ip, {}).get("comment", "")
return f"{ip} ({comment})" if comment else ip
# ──────────────────────────────────────────────
# Статус-отчёт
# ──────────────────────────────────────────────
def build_status_report(title: str) -> str:
now = datetime.now()
lines = [f"<b>{title}</b>", f"🔧 Роутер: <b>{router_identity}</b>", ""]
down = [ip for ip, s in hosts_state.items() if s["status"] == "down"]
up = [ip for ip, s in hosts_state.items() if s["status"] == "up"]
if down:
lines.append(f"🔴 <b>Недоступны ({len(down)}):</b>")
for ip in down:
ds = hosts_state[ip].get("down_since")
dur = fmt_duration(now - ds) if ds else "?"
lines.append(f" • <code>{fmt_label(ip)}</code> — {dur}")
lines.append("")
if up:
lines.append(f"✅ <b>Доступны ({len(up)}):</b>")
for ip in up:
rtt = hosts_state[ip].get("rtt")
lines.append(f" • <code>{fmt_label(ip)}</code>" + (f" {rtt:.1f} ms" if rtt else ""))
lines += ["", f"{now.strftime('%Y-%m-%d %H:%M:%S')}"]
return "\n".join(lines)
# ──────────────────────────────────────────────
# Команды ботов
# ──────────────────────────────────────────────
async def process_telegram_commands() -> None:
global tg_last_update_id, _tg_bot_username
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
return
async with httpx.AsyncClient(timeout=15) as client:
try:
resp = await client.get(
f"{TELEGRAM_API_URL}/bot{TELEGRAM_BOT_TOKEN}/getUpdates",
params={"timeout": 1, "offset": tg_last_update_id + 1},
)
data = resp.json()
for update in data.get("result", []):
tg_last_update_id = update["update_id"]
text = update.get("message", {}).get("text", "").strip().lower().split("@")[0]
if text == "/status":
await send_telegram(build_status_report("📊 Текущий статус"))
elif text == "/help":
await send_telegram(
"🤖 <b>Доступные команды:</b>\n"
"/status — статус всех хостов\n"
"/help — это сообщение"
)
except Exception as exc:
log.debug("TG polling: %s", exc)
async def process_max_commands() -> None:
global max_last_marker
if not MAX_BOT_TOKEN or not MAX_CHAT_ID:
return
params = {"access_token": MAX_BOT_TOKEN}
if max_last_marker:
params["marker"] = max_last_marker
async with httpx.AsyncClient(timeout=15) as client:
try:
resp = await client.get(f"{MAX_API_URL}/updates", params=params)
data = resp.json()
max_last_marker = data.get("marker", max_last_marker)
for update in data.get("updates", []):
if update.get("update_type") != "message_created":
continue
text = update.get("message", {}).get("body", {}).get("text", "").strip().lower()
if text == "/status":
await send_max(build_status_report("📊 Текущий статус"))
elif text == "/help":
await send_max(
"🤖 <b>Доступные команды:</b>\n"
"/status — статус всех хостов\n"
"/help — это сообщение"
)
except Exception as exc:
log.debug("MAX polling: %s", exc)
async def process_bot_commands() -> None:
await asyncio.gather(process_telegram_commands(), process_max_commands())
# ──────────────────────────────────────────────
# Ежедневный отчёт
# ──────────────────────────────────────────────
def check_daily_report() -> None:
global daily_report_sent_date
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if now.strftime("%H:%M") == DAILY_REPORT_TIME and daily_report_sent_date != today:
daily_report_sent_date = today
notify(build_status_report(f"📅 Ежедневный отчёт — {today}"))
log.info("Ежедневный отчёт отправлен")
# ──────────────────────────────────────────────
# Веб-дашборд
# ──────────────────────────────────────────────
class DashboardHandler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path not in ("/", "/status"):
self.send_response(404)
self.end_headers()
return
now = datetime.now()
rows = ""
for ip, state in hosts_state.items():
comment = state.get("comment", "")
status = state.get("status", "unknown")
rtt = state.get("rtt")
ds = state.get("down_since")
ls = state.get("last_seen")
if status == "up":
badge = '<span class="badge up">● UP</span>'
rtt_cell = f"{rtt:.1f} ms" if rtt else ""
dur_cell = ""
elif status == "down":
badge = '<span class="badge down">● DOWN</span>'
rtt_cell = ""
dur_cell = fmt_duration(now - ds) if ds else ""
else:
badge = '<span class="badge unknown">● ?</span>'
rtt_cell = dur_cell = ""
rows += f"""<tr class="{status}">
<td><code>{ip}</code></td><td>{comment}</td><td>{badge}</td>
<td>{rtt_cell}</td><td>{dur_cell}</td>
<td>{ls.strftime('%H:%M:%S') if ls else ''}</td></tr>"""
total = len(hosts_state)
up_count = sum(1 for s in hosts_state.values() if s.get("status") == "up")
down_count= sum(1 for s in hosts_state.values() if s.get("status") == "down")
last_str = last_check_time.strftime("%Y-%m-%d %H:%M:%S") if last_check_time else ""
html = f"""<!DOCTYPE html><html lang="ru"><head>
<meta charset="UTF-8"><meta http-equiv="refresh" content="{CHECK_INTERVAL}">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MikroTik Monitor — {router_identity}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e0e0e0;padding:24px}}
h1{{font-size:1.4rem;color:#fff;margin-bottom:4px}}
.sub{{color:#888;font-size:.85rem;margin-bottom:24px}}
.cards{{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}}
.card{{background:#1a1d27;border-radius:10px;padding:16px 24px;min-width:130px}}
.card .num{{font-size:2rem;font-weight:700}}.card .lbl{{font-size:.8rem;color:#888;margin-top:2px}}
.green{{color:#4caf50}}.red{{color:#f44336}}.gray{{color:#aaa}}
table{{width:100%;border-collapse:collapse;background:#1a1d27;border-radius:10px;overflow:hidden}}
th{{background:#22263a;color:#aaa;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;padding:10px 14px;text-align:left}}
td{{padding:10px 14px;font-size:.9rem;border-top:1px solid #22263a}}
tr.down td{{background:rgba(244,67,54,.07)}}
code{{background:#22263a;padding:2px 6px;border-radius:4px;font-size:.85rem}}
.badge{{padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600}}
.badge.up{{background:rgba(76,175,80,.15);color:#4caf50}}
.badge.down{{background:rgba(244,67,54,.15);color:#f44336}}
.badge.unknown{{background:rgba(158,158,158,.15);color:#9e9e9e}}
.footer{{margin-top:16px;font-size:.78rem;color:#555}}
</style></head><body>
<h1>🔍 MikroTik Monitor</h1>
<div class="sub">Роутер: <b>{router_identity}</b> ({MIKROTIK_HOST}) &nbsp;|&nbsp;
List: <b>{ADDRESS_LIST_NAME}</b> &nbsp;|&nbsp; Проверка: {last_str}</div>
<div class="cards">
<div class="card"><div class="num gray">{total}</div><div class="lbl">Всего</div></div>
<div class="card"><div class="num green">{up_count}</div><div class="lbl">Доступны</div></div>
<div class="card"><div class="num red">{down_count}</div><div class="lbl">Недоступны</div></div>
</div>
<table><thead><tr>
<th>IP</th><th>Имя</th><th>Статус</th><th>RTT</th><th>Даунтайм</th><th>Последний ответ</th>
</tr></thead><tbody>{rows}</tbody></table>
<div class="footer">Автообновление каждые {CHECK_INTERVAL} сек</div>
</body></html>"""
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def start_web_server() -> None:
server = HTTPServer(("0.0.0.0", WEB_PORT), DashboardHandler)
log.info("Веб-дашборд: http://0.0.0.0:%d", WEB_PORT)
server.serve_forever()
# ──────────────────────────────────────────────
# Основной цикл
# ──────────────────────────────────────────────
def main() -> None:
log.info("=== MikroTik Monitor запущен ===")
log.info("Address-list: '%s'", ADDRESS_LIST_NAME)
log.info("Интервал: %d сек", CHECK_INTERVAL)
global router_identity, last_check_time
# Состояние хостов: счётчик ошибок и флаг DOWN
# Обновляется динамически при каждом чтении address-list
failure_count: dict[str, int] = {}
host_down: dict[str, bool] = {}
log.info("=== MikroTik Monitor запущен ===")
log.info("Address-list: '%s' Интервал: %d сек", ADDRESS_LIST_NAME, CHECK_INTERVAL)
threading.Thread(target=start_web_server, daemon=True).start()
startup_notified = False
while True:
pool = None
try:
log.info("Подключение к MikroTik %s:%d", MIKROTIK_HOST, MIKROTIK_PORT)
pool = routeros_api.RouterOsApiPool(
MIKROTIK_HOST,
username=MIKROTIK_USER,
@@ -251,77 +439,104 @@ def main() -> None:
api = pool.get_api()
log.info("Подключено к MikroTik")
# Читаем актуальный список адресов с MikroTik
ip_list = fetch_address_list(api)
# Стартовое сообщение — отправляем только после успешного подключения
if not startup_notified:
identity = fetch_identity(api)
router_identity = fetch_identity(api)
ip_lines = "\n".join(
f" • <code>{ip}</code> ({comment})" if comment else f" • <code>{ip}</code>"
for ip, comment in ip_list
) or " (список пуст)"
notify(
f"🟢 <b>MikroTik Monitor запущен</b>\n"
f"\n"
f"🔧 Роутер: <b>{identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
f"📋 Address-list: <code>{ADDRESS_LIST_NAME}</code>\n"
f"\n"
f"<b>Мониторинг адресов:</b>\n{ip_lines}\n"
f"\n"
f"🟢 <b>MikroTik Monitor запущен</b>\n\n"
f"🔧 Роутер: <b>{router_identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
f"📋 Address-list: <code>{ADDRESS_LIST_NAME}</code>\n\n"
f"<b>Мониторинг адресов:</b>\n{ip_lines}\n\n"
f"⏱ Интервал: {CHECK_INTERVAL} сек"
)
startup_notified = True
if not ip_list:
log.warning("Address-list '%s' пуст или не найден!", ADDRESS_LIST_NAME)
log.warning("Address-list '%s' пуст!", ADDRESS_LIST_NAME)
else:
log.info("Адресов для проверки: %d%s", len(ip_list), ip_list)
log.info("Адресов: %d", len(ip_list))
# Добавляем новые хосты в state (без сброса существующих)
now = datetime.now()
for ip, comment in ip_list:
if ip not in failure_count:
failure_count[ip] = 0
host_down[ip] = False
if ip not in hosts_state:
hosts_state[ip] = {
"comment": comment, "status": "unknown",
"rtt": None, "down_since": None,
"last_seen": None, "last_notify": None,
}
else:
hosts_state[ip]["comment"] = comment
# Параллельный пинг
ping_results = asyncio.run(ping_all_async(api, ip_list))
last_check_time = datetime.now()
now = last_check_time
# Пингуем каждый адрес
for ip, comment in ip_list:
label = f"{ip} ({comment})" if comment else ip
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rtt = mikrotik_ping(api, ip)
rtt = ping_results.get(ip)
state = hosts_state[ip]
label = fmt_label(ip)
ts = now.strftime("%Y-%m-%d %H:%M:%S")
if rtt is not None:
log.info("%-28s avg-rtt=%.1f ms", label, rtt)
if host_down[ip]:
host_down[ip] = False
failure_count[ip] = 0
log.info("%-34s %.1f ms", label, rtt)
state["rtt"] = rtt
state["last_seen"] = now
if state["status"] == "down":
ds = state.get("down_since")
dur = fmt_duration(now - ds) if ds else "?"
state.update({"status": "up", "down_since": None, "last_notify": None})
notify(
f"✅ <b>Хост восстановился</b>\n"
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + f"\n"
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
f"RTT: {rtt:.1f} ms\n"
f"Время: {now}"
f"Даунтайм: {dur}\n"
f"Время: {ts}"
)
else:
failure_count[ip] = 0
state["status"] = "up"
else:
failure_count[ip] += 1
log.warning(
"%-28s не отвечает (%d/%d)",
label, failure_count[ip], FAILURE_THRESHOLD,
)
if failure_count[ip] >= FAILURE_THRESHOLD and not host_down[ip]:
host_down[ip] = True
log.warning("%-34s нет ответа", label)
state["rtt"] = None
if state["status"] != "down":
state.update({"status": "down", "down_since": now, "last_notify": now})
notify(
f"🔴 <b>Хост недоступен!</b>\n"
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + f"\n"
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
f"Список: <code>{ADDRESS_LIST_NAME}</code>\n"
f"Не отвечает {failure_count[ip]} проверки подряд\n"
f"Время: {now}"
f"Время: {ts}"
)
elif REPEAT_NOTIFY_MIN > 0 and state.get("last_notify"):
mins = (now - state["last_notify"]).total_seconds() / 60
if mins >= REPEAT_NOTIFY_MIN:
ds = state.get("down_since")
dur = fmt_duration(now - ds) if ds else "?"
state["last_notify"] = now
notify(
f"🔴 <b>Хост всё ещё недоступен!</b>\n"
f"IP: <code>{ip}</code>" + (f" ({comment})" if comment else "") + "\n"
f"Недоступен уже: {dur}\n"
f"Время: {ts}"
)
asyncio.run(process_bot_commands())
check_daily_report()
except Exception as exc:
log.error("Ошибка: %s", exc)
notify(f"⚠️ <b>Ошибка подключения к MikroTik</b>\n<code>{exc}</code>")
notify(
f"⚠️ <b>Ошибка подключения к MikroTik</b>\n"
f"🔧 Роутер: <b>{router_identity}</b> (<code>{MIKROTIK_HOST}</code>)\n"
f"<code>{exc}</code>"
)
finally:
if pool:
try: