mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-11-04 00:43:06 +00:00
feat: initial VPS monitoring platform
- FastAPI backend with auth, server CRUD, metrics collection - Web dashboard with dark theme, server cards, SSH terminal (xterm.js) - Telegram bot with inline keyboards, server management, reboot - Telegram Mini App for mobile monitoring - Background monitoring via asyncssh (CPU, RAM, Disk, Network, Uptime) - Alert system with configurable thresholds - WebSocket SSH terminal in browser - One-command install script - Systemd service configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
server/services/__init__.py
Normal file
0
server/services/__init__.py
Normal file
56
server/services/alerter.py
Normal file
56
server/services/alerter.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from server.config import load_settings, load_servers
|
||||
from server.services.monitor import get_all_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
previous_states: Dict[str, bool] = {}
|
||||
|
||||
|
||||
async def check_alerts():
|
||||
"""Check metrics against thresholds and send alerts."""
|
||||
from server.services.telegram_bot import send_alert
|
||||
|
||||
settings = load_settings()
|
||||
servers = load_servers()
|
||||
metrics = get_all_metrics()
|
||||
|
||||
cpu_threshold = settings.get("alert_cpu_threshold", 90)
|
||||
ram_threshold = settings.get("alert_ram_threshold", 90)
|
||||
disk_threshold = settings.get("alert_disk_threshold", 90)
|
||||
|
||||
for srv in servers:
|
||||
host = srv["host"]
|
||||
m = metrics.get(host, {})
|
||||
name = srv.get("name", host)
|
||||
|
||||
current_online = m.get("online", False)
|
||||
prev_online = previous_states.get(host)
|
||||
|
||||
# Online/Offline state change
|
||||
if prev_online is not None and prev_online != current_online:
|
||||
if current_online:
|
||||
await send_alert(f"🟢 **{name}** ({host}) — снова онлайн")
|
||||
else:
|
||||
await send_alert(f"🔴 **{name}** ({host}) — OFFLINE!")
|
||||
|
||||
previous_states[host] = current_online
|
||||
|
||||
if not current_online:
|
||||
continue
|
||||
|
||||
# Threshold alerts
|
||||
cpu = m.get("cpu_percent", 0)
|
||||
if cpu >= cpu_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)")
|
||||
|
||||
ram = m.get("ram_percent", 0)
|
||||
if ram >= ram_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)")
|
||||
|
||||
disk = m.get("disk_percent", 0)
|
||||
if disk >= disk_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)")
|
||||
163
server/services/monitor.py
Normal file
163
server/services/monitor.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
import asyncssh
|
||||
|
||||
from server.config import load_servers, load_settings, DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
metrics_cache: Dict[str, dict] = {}
|
||||
|
||||
|
||||
async def collect_server_metrics(server: dict) -> dict:
|
||||
"""Connect to server via SSH and collect system metrics."""
|
||||
metrics = {
|
||||
"cpu_percent": 0,
|
||||
"ram_percent": 0,
|
||||
"ram_used_mb": 0,
|
||||
"ram_total_mb": 0,
|
||||
"disk_percent": 0,
|
||||
"disk_used_gb": 0,
|
||||
"disk_total_gb": 0,
|
||||
"network_in_mb": 0,
|
||||
"network_out_mb": 0,
|
||||
"uptime": "",
|
||||
"load_average": "",
|
||||
"online": False,
|
||||
"last_check": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
try:
|
||||
conn_kwargs = {
|
||||
"host": server["host"],
|
||||
"port": server.get("port", 22),
|
||||
"username": server.get("username", "root"),
|
||||
"known_hosts": None,
|
||||
"connect_timeout": 10,
|
||||
}
|
||||
|
||||
if server.get("password"):
|
||||
conn_kwargs["password"] = server["password"]
|
||||
elif server.get("ssh_key"):
|
||||
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||
|
||||
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||
# CPU usage
|
||||
result = await conn.run(
|
||||
"top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1",
|
||||
check=False
|
||||
)
|
||||
if result.stdout.strip():
|
||||
try:
|
||||
metrics["cpu_percent"] = round(float(result.stdout.strip()), 1)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# RAM
|
||||
result = await conn.run(
|
||||
"free -m | awk 'NR==2{printf \"%s %s %s\", $2, $3, $3/$2*100}'",
|
||||
check=False
|
||||
)
|
||||
if result.stdout.strip():
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
metrics["ram_total_mb"] = float(parts[0])
|
||||
metrics["ram_used_mb"] = float(parts[1])
|
||||
metrics["ram_percent"] = round(float(parts[2]), 1)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Disk
|
||||
result = await conn.run(
|
||||
"df -BG / | awk 'NR==2{print $2, $3, $5}'",
|
||||
check=False
|
||||
)
|
||||
if result.stdout.strip():
|
||||
parts = result.stdout.strip().replace("G", "").split()
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
metrics["disk_total_gb"] = float(parts[0])
|
||||
metrics["disk_used_gb"] = float(parts[1])
|
||||
metrics["disk_percent"] = float(parts[2].replace("%", ""))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Network
|
||||
result = await conn.run(
|
||||
"cat /proc/net/dev | awk 'NR>2{rx+=$2; tx+=$10} END{printf \"%.0f %.0f\", rx/1048576, tx/1048576}'",
|
||||
check=False
|
||||
)
|
||||
if result.stdout.strip():
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
metrics["network_in_mb"] = float(parts[0])
|
||||
metrics["network_out_mb"] = float(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Uptime
|
||||
result = await conn.run("uptime -p", check=False)
|
||||
if result.stdout.strip():
|
||||
metrics["uptime"] = result.stdout.strip()
|
||||
|
||||
# Load average
|
||||
result = await conn.run("cat /proc/loadavg | awk '{print $1, $2, $3}'", check=False)
|
||||
if result.stdout.strip():
|
||||
metrics["load_average"] = result.stdout.strip()
|
||||
|
||||
metrics["online"] = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect metrics from {server.get('name', server['host'])}: {e}")
|
||||
metrics["online"] = False
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
async def monitor_loop():
|
||||
"""Background monitoring loop."""
|
||||
while True:
|
||||
try:
|
||||
settings = load_settings()
|
||||
servers = load_servers()
|
||||
interval = settings.get("monitor_interval", 60)
|
||||
|
||||
tasks = []
|
||||
for srv in servers:
|
||||
tasks.append(collect_server_metrics(srv))
|
||||
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for srv, result in zip(servers, results):
|
||||
if isinstance(result, Exception):
|
||||
metrics_cache[srv["host"]] = {
|
||||
"online": False,
|
||||
"last_check": datetime.now().isoformat(),
|
||||
}
|
||||
else:
|
||||
metrics_cache[srv["host"]] = result
|
||||
|
||||
# Save metrics to file
|
||||
metrics_file = DATA_DIR / "metrics.json"
|
||||
with open(metrics_file, "w") as f:
|
||||
json.dump(metrics_cache, f, indent=2)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Monitor loop error: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def get_metrics(host: str) -> dict:
|
||||
return metrics_cache.get(host, {})
|
||||
|
||||
|
||||
def get_all_metrics() -> dict:
|
||||
return metrics_cache.copy()
|
||||
69
server/services/ssh_manager.py
Normal file
69
server/services/ssh_manager.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
import asyncssh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
active_sessions: Dict[str, asyncssh.SSHClientConnection] = {}
|
||||
|
||||
|
||||
async def create_ssh_session(server: dict) -> Optional[asyncssh.SSHClientConnection]:
|
||||
"""Create SSH connection to server."""
|
||||
try:
|
||||
conn_kwargs = {
|
||||
"host": server["host"],
|
||||
"port": server.get("port", 22),
|
||||
"username": server.get("username", "root"),
|
||||
"known_hosts": None,
|
||||
"connect_timeout": 15,
|
||||
}
|
||||
|
||||
if server.get("password"):
|
||||
conn_kwargs["password"] = server["password"]
|
||||
elif server.get("ssh_key"):
|
||||
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||
|
||||
conn = await asyncssh.connect(**conn_kwargs)
|
||||
active_sessions[server["host"]] = conn
|
||||
return conn
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SSH connection failed to {server['host']}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def execute_command(server: dict, command: str) -> dict:
|
||||
"""Execute command on remote server."""
|
||||
try:
|
||||
conn_kwargs = {
|
||||
"host": server["host"],
|
||||
"port": server.get("port", 22),
|
||||
"username": server.get("username", "root"),
|
||||
"known_hosts": None,
|
||||
"connect_timeout": 15,
|
||||
}
|
||||
|
||||
if server.get("password"):
|
||||
conn_kwargs["password"] = server["password"]
|
||||
elif server.get("ssh_key"):
|
||||
conn_kwargs["client_keys"] = [server["ssh_key"]]
|
||||
|
||||
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||
result = await conn.run(command, check=False, timeout=30)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": result.exit_status,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"stdout": "", "stderr": str(e), "exit_code": -1}
|
||||
|
||||
|
||||
async def close_session(host: str):
|
||||
"""Close SSH session."""
|
||||
conn = active_sessions.pop(host, None)
|
||||
if conn:
|
||||
conn.close()
|
||||
295
server/services/telegram_bot.py
Normal file
295
server/services/telegram_bot.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
CallbackQueryHandler,
|
||||
ContextTypes,
|
||||
)
|
||||
|
||||
from server.config import load_settings, load_servers
|
||||
from server.services.monitor import get_all_metrics, collect_server_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bot_app: Optional[Application] = None
|
||||
|
||||
|
||||
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
settings = load_settings()
|
||||
servers = load_servers()
|
||||
metrics = get_all_metrics()
|
||||
|
||||
online = sum(1 for s in servers if metrics.get(s["host"], {}).get("online", False))
|
||||
offline = len(servers) - online
|
||||
|
||||
text = (
|
||||
f"🖥 **VPS Monitoring**\n\n"
|
||||
f"Серверов: {len(servers)}\n"
|
||||
f"🟢 Online: {online}\n"
|
||||
f"🔴 Offline: {offline}\n\n"
|
||||
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")],
|
||||
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")],
|
||||
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")],
|
||||
]
|
||||
|
||||
# Add Web App button if available
|
||||
webapp_url = f"http://{_get_host()}:8000/telegram-app"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("📱 Открыть панель", web_app=WebAppInfo(url=webapp_url))
|
||||
])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode="Markdown")
|
||||
|
||||
|
||||
async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
servers = load_servers()
|
||||
metrics = get_all_metrics()
|
||||
|
||||
if not servers:
|
||||
await query.edit_message_text("Нет добавленных серверов")
|
||||
return
|
||||
|
||||
text = "📊 **Статус серверов:**\n\n"
|
||||
for srv in servers:
|
||||
m = metrics.get(srv["host"], {})
|
||||
status = "🟢" if m.get("online") else "🔴"
|
||||
cpu = m.get("cpu_percent", 0)
|
||||
ram = m.get("ram_percent", 0)
|
||||
disk = m.get("disk_percent", 0)
|
||||
text += (
|
||||
f"{status} **{srv['name']}** ({srv['host']})\n"
|
||||
f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n"
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🔄 Обновить", callback_data="status")],
|
||||
[InlineKeyboardButton("◀️ Назад", callback_data="back")],
|
||||
]
|
||||
await query.edit_message_text(
|
||||
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def manage_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
servers = load_servers()
|
||||
keyboard = []
|
||||
|
||||
for i, srv in enumerate(servers):
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(f"🖥 {srv['name']}", callback_data=f"server_{i}")
|
||||
])
|
||||
|
||||
keyboard.append([InlineKeyboardButton("◀️ Назад", callback_data="back")])
|
||||
await query.edit_message_text(
|
||||
"⚙️ **Управление серверами:**\nВыберите сервер:",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard),
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
async def server_action_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
query = update.callback_query
|
||||
await query.answer()
|
||||
|
||||
data = query.data
|
||||
if data.startswith("server_"):
|
||||
idx = int(data.split("_")[1])
|
||||
servers = load_servers()
|
||||
if idx >= len(servers):
|
||||
await query.edit_message_text("Сервер не найден")
|
||||
return
|
||||
|
||||
srv = servers[idx]
|
||||
metrics = get_all_metrics()
|
||||
m = metrics.get(srv["host"], {})
|
||||
|
||||
status = "🟢 Online" if m.get("online") else "🔴 Offline"
|
||||
text = (
|
||||
f"🖥 **{srv['name']}**\n"
|
||||
f"Host: `{srv['host']}`\n"
|
||||
f"Status: {status}\n"
|
||||
f"CPU: {m.get('cpu_percent', 0)}%\n"
|
||||
f"RAM: {m.get('ram_percent', 0)}% ({m.get('ram_used_mb', 0):.0f}/{m.get('ram_total_mb', 0):.0f} MB)\n"
|
||||
f"Disk: {m.get('disk_percent', 0)}% ({m.get('disk_used_gb', 0):.1f}/{m.get('disk_total_gb', 0):.1f} GB)\n"
|
||||
f"Uptime: {m.get('uptime', 'N/A')}\n"
|
||||
f"Load: {m.get('load_average', 'N/A')}\n"
|
||||
)
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("🔄 Перезагрузить", callback_data=f"reboot_{idx}")],
|
||||
[InlineKeyboardButton("📊 Обновить метрики", callback_data=f"refresh_{idx}")],
|
||||
[InlineKeyboardButton("◀️ Назад", callback_data="manage")],
|
||||
]
|
||||
await query.edit_message_text(
|
||||
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||
)
|
||||
|
||||
elif data.startswith("reboot_"):
|
||||
idx = int(data.split("_")[1])
|
||||
servers = load_servers()
|
||||
if idx >= len(servers):
|
||||
return
|
||||
|
||||
srv = servers[idx]
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("✅ Да, перезагрузить", callback_data=f"confirm_reboot_{idx}")],
|
||||
[InlineKeyboardButton("❌ Отмена", callback_data=f"server_{idx}")],
|
||||
]
|
||||
await query.edit_message_text(
|
||||
f"⚠️ Перезагрузить сервер **{srv['name']}** ({srv['host']})?",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard),
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
elif data.startswith("confirm_reboot_"):
|
||||
idx = int(data.split("_")[2])
|
||||
servers = load_servers()
|
||||
if idx >= len(servers):
|
||||
return
|
||||
|
||||
srv = servers[idx]
|
||||
try:
|
||||
import asyncssh
|
||||
conn_kwargs = {
|
||||
"host": srv["host"],
|
||||
"port": srv.get("port", 22),
|
||||
"username": srv.get("username", "root"),
|
||||
"known_hosts": None,
|
||||
}
|
||||
if srv.get("password"):
|
||||
conn_kwargs["password"] = srv["password"]
|
||||
|
||||
async with asyncssh.connect(**conn_kwargs) as conn:
|
||||
await conn.run("reboot", check=False)
|
||||
|
||||
await query.edit_message_text(
|
||||
f"✅ Команда перезагрузки отправлена на **{srv['name']}**",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
except Exception as e:
|
||||
await query.edit_message_text(
|
||||
f"❌ Ошибка: {e}",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
elif data.startswith("refresh_"):
|
||||
idx = int(data.split("_")[1])
|
||||
servers = load_servers()
|
||||
if idx >= len(servers):
|
||||
return
|
||||
|
||||
srv = servers[idx]
|
||||
await query.edit_message_text("⏳ Собираю метрики...")
|
||||
|
||||
from server.services.monitor import metrics_cache
|
||||
result = await collect_server_metrics(srv)
|
||||
metrics_cache[srv["host"]] = result
|
||||
|
||||
# Re-show server info
|
||||
context.user_data["refresh_target"] = idx
|
||||
await server_action_callback(update, context)
|
||||
|
||||
elif data == "refresh":
|
||||
await status_callback(update, context)
|
||||
|
||||
elif data == "back":
|
||||
# Simulate /start
|
||||
servers = load_servers()
|
||||
metrics = get_all_metrics()
|
||||
online = sum(1 for s in servers if metrics.get(s["host"], {}).get("online", False))
|
||||
offline = len(servers) - online
|
||||
|
||||
text = (
|
||||
f"🖥 **VPS Monitoring**\n\n"
|
||||
f"Серверов: {len(servers)}\n"
|
||||
f"🟢 Online: {online}\n"
|
||||
f"🔴 Offline: {offline}\n\n"
|
||||
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
keyboard = [
|
||||
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")],
|
||||
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")],
|
||||
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")],
|
||||
]
|
||||
await query.edit_message_text(
|
||||
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def send_alert(message: str):
|
||||
"""Send alert to Telegram chat."""
|
||||
settings = load_settings()
|
||||
token = settings.get("telegram_bot_token")
|
||||
chat_id = settings.get("telegram_chat_id")
|
||||
|
||||
if not token or not chat_id:
|
||||
return
|
||||
|
||||
try:
|
||||
if bot_app and bot_app.bot:
|
||||
await bot_app.bot.send_message(chat_id=chat_id, text=message, parse_mode="Markdown")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram alert: {e}")
|
||||
|
||||
|
||||
def _get_host():
|
||||
"""Get public host for web app URL."""
|
||||
import socket
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "localhost"
|
||||
|
||||
|
||||
async def start_bot():
|
||||
"""Initialize and start the Telegram bot."""
|
||||
global bot_app
|
||||
|
||||
settings = load_settings()
|
||||
token = settings.get("telegram_bot_token")
|
||||
|
||||
if not token:
|
||||
logger.warning("Telegram bot token not configured")
|
||||
return
|
||||
|
||||
bot_app = Application.builder().token(token).build()
|
||||
|
||||
bot_app.add_handler(CommandHandler("start", start_command))
|
||||
bot_app.add_handler(CallbackQueryHandler(status_callback, pattern="^status$"))
|
||||
bot_app.add_handler(CallbackQueryHandler(manage_callback, pattern="^manage$"))
|
||||
bot_app.add_handler(CallbackQueryHandler(server_action_callback, pattern="^(server_|reboot_|confirm_reboot_|refresh_|refresh$|back$)"))
|
||||
|
||||
await bot_app.initialize()
|
||||
await bot_app.start()
|
||||
await bot_app.updater.start_polling(drop_pending_updates=True)
|
||||
logger.info("Telegram bot started")
|
||||
|
||||
|
||||
async def stop_bot():
|
||||
"""Stop the Telegram bot."""
|
||||
global bot_app
|
||||
if bot_app:
|
||||
await bot_app.updater.stop()
|
||||
await bot_app.stop()
|
||||
await bot_app.shutdown()
|
||||
bot_app = None
|
||||
Reference in New Issue
Block a user