diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..aa3d724 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +TOKEN= +USER_ID= diff --git a/.gitignore b/.gitignore index 36b13f1..a8619ae 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,4 @@ cython_debug/ # PyPI configuration file .pypirc +uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c87589 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "server-bot-python" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "aiogram>=3.27.0", + "psutil>=7.2.2", + "pydantic-settings>=2.14.0", + "python-dotenv>=1.2.2", + "types-psutil>=7.2.2.20260408", +] + +[dependency-groups] +dev = [ + "deptry>=0.25.1", + "mypy>=1.20.1", + "ruff>=0.15.11", +] + + +[tool.ruff] +line-length = 160 +target-version = "py313" + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "Q", "ERA", "D", "SIM", "ARG", "RUF", "C4", "RET", "ASYNC", "PERF", "PL"] +ignore = ["D100", "RUF002", "D107", "RUF001", "D104", "D203", "D213", "ARG001"] + +[tool.ruff.lint.flake8-quotes] +inline-quotes = "single" +multiline-quotes = "double" +docstring-quotes = "double" +avoid-escape = true + +[tool.mypy] +python_version = "3.13" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a138e05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv +psutil +aiogram diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..686cd28 --- /dev/null +++ b/src/config.py @@ -0,0 +1,21 @@ +from pydantic import Field, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """App settings loaded from environment.""" + + TOKEN: str = Field(default='default_value', description='Токен Telegram-бота') + USER_ID: int = Field(default=0, description='ID пользователя для доступа') + + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + + +try: + settings = Settings() +except ValidationError as exc: + msg = f'Ошибка валидации настроек: {exc}' + raise RuntimeError(msg) from exc + +TOKEN: str = settings.TOKEN +USER_ID: str = str(settings.USER_ID) diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/commands.py b/src/handlers/commands.py new file mode 100644 index 0000000..e72f46a --- /dev/null +++ b/src/handlers/commands.py @@ -0,0 +1,96 @@ +import logging +import os + +import psutil +from aiogram import Router +from aiogram.enums import ParseMode +from aiogram.filters import Command +from aiogram.types import Message + +from src.config import USER_ID +from src.utils.system import execute_command, get_cpu_temperature + +router = Router() + +current_directory = os.getcwd() + + +@router.message(Command('start')) +async def cmd_start(message: Message) -> None: + """Команда /start - приветствие и краткая инструкция.""" + await message.answer('Привет! Я бот для мониторинга сервера. Используй /help для списка команд.') + + +@router.message(Command('help')) +async def cmd_help(message: Message) -> None: + """Команда /help - показать список доступных команд и их описание.""" + help_text = ( + '📋 Список команд:\n' + '/start - Начать работу с ботом\n' + '/help - Показать это сообщение\n' + '/status - Показать текущее состояние сервера\n' + '/c <команда> - Выполнить команду на сервере\n' + '/sysinfo - Показать информацию о системе' + ) + await message.answer(help_text, parse_mode=ParseMode.HTML) + + +@router.message(Command('status')) +async def cmd_status(message: Message) -> None: + """Выводит текущую загрузку CPU, RAM, диска и температуру CPU.""" + cpu_usage = psutil.cpu_percent(interval=1) + ram_usage = psutil.virtual_memory().percent + disk_usage = psutil.disk_usage('/').percent + cpu_temp = get_cpu_temperature() + + status_message = f'📊 Состояние сервера:\n• CPU: {cpu_usage}%\n• RAM: {ram_usage}%\n• Диск: {disk_usage}%\n' + if cpu_temp is not None: + status_message += f'• Температура CPU: {cpu_temp}°C\n' + else: + status_message += '• Температура CPU: N/A\n' + await message.answer(status_message, parse_mode=ParseMode.HTML) + + +@router.message(Command('c')) +async def cmd_execute(message: Message) -> None: + """Команда /c <команда> - выполняет указанную команду на сервере и возвращает результат.""" + global current_directory + user_id = message.from_user.id if message.from_user else 'Unknown' + if str(user_id) != str(USER_ID): + await message.answer('❌ У вас нет прав для использования этого бота.') + return + + if not message.text: + await message.answer('❌ Укажите команду для выполнения.') + return + command = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else None + if not command: + await message.answer('❌ Укажите команду для выполнения.') + return + + logging.info(f'Пользователь {user_id} выполнил команду: {command}') + result, current_directory = execute_command(command, current_directory) + await message.answer(f'✅ Результат выполнения команды:\n
{result}
', parse_mode=ParseMode.HTML) + + +@router.message(Command('sysinfo')) +async def cmd_sysinfo(message: Message) -> None: + """Sysinfo - Показать информацию о системе, включая имя хоста, ОС и количество ядер CPU.""" + """Команда /sysinfo - выводит информацию о системе, включая имя хоста, ОС и количество ядер CPU.""" + user_id = message.from_user.id if message.from_user else 'Unknown' + if str(user_id) != str(USER_ID): + await message.answer('❌ У вас нет прав для использования этого бота.') + return + logging.info(f'Пользователь {user_id} выполнил команду /sysinfo') + try: + hostname = os.uname().nodename if hasattr(os, 'uname') else os.getenv('COMPUTERNAME', 'N/A') + os_info = os.uname().sysname + ' ' + os.uname().release if hasattr(os, 'uname') else os.name + cpu_count = os.cpu_count() + + sysinfo_message = ( + f'📊 Информация о системе:\n• Имя хоста: {hostname}\n• ОС: {os_info}\n• Количество ядер CPU: {cpu_count}\n' + ) + + await message.answer(sysinfo_message, parse_mode=ParseMode.HTML) + except Exception as e: + await message.answer(f'❌ Ошибка:\n
{e!s}
', parse_mode=ParseMode.HTML) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..0b7dd69 --- /dev/null +++ b/src/main.py @@ -0,0 +1,35 @@ +import logging + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode + +from src.config import settings +from src.handlers import commands +from src.middlewares.access import AccessMiddleware + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[logging.FileHandler('bot.log'), logging.StreamHandler()], +) + + +def setup_routers(dp: Dispatcher) -> None: + """Регистрирует роутеры и мидлвары в диспетчере.""" + dp.message.middleware(AccessMiddleware()) + dp.include_router(commands.router) + + +async def main() -> None: + """Создает экземпляр бота, регистрирует роутеры и запускает поллинг.""" + bot = Bot(token=settings.TOKEN, parse_mode=ParseMode.HTML) + dp = Dispatcher() + setup_routers(dp) + logging.info('Бот запущен.') + await dp.start_polling(bot) + + +if __name__ == '__main__': + import asyncio + + asyncio.run(main()) diff --git a/src/middlewares/__init__.py b/src/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/middlewares/access.py b/src/middlewares/access.py new file mode 100644 index 0000000..5243e95 --- /dev/null +++ b/src/middlewares/access.py @@ -0,0 +1,28 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram.types import Message, TelegramObject + +from src.config import USER_ID + + +class AccessMiddleware(BaseMiddleware): + """Мидлвар для проверки доступа по USER_ID. Если ID пользователя не совпадает, отправляет сообщение об ошибке и не пропускает дальше.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + """Проверяет ID пользователя только для Message.""" + if isinstance(event, Message): + if event.from_user is None: + return None + + if str(event.from_user.id) != USER_ID: + await event.answer('❌ У вас нет прав для использования этого бота.') + return None + + return await handler(event, data) diff --git a/src/middlewares/logging.py b/src/middlewares/logging.py new file mode 100644 index 0000000..65004a8 --- /dev/null +++ b/src/middlewares/logging.py @@ -0,0 +1,20 @@ +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram.dispatcher.middlewares.base import BaseMiddleware +from aiogram.types import TelegramObject + + +class LoggingMiddleware(BaseMiddleware): + """Мидлвар для логирования всех входящих обновлений. Логирует тип и содержимое обновления. Логи сохраняются в файл bot.log и выводятся в консоль.""" + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + """Выполняет логирование входящего обновления и передает его дальше по цепочке обработки.""" + logging.info('Update: %s', event) + return await handler(event, data) diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/system.py b/src/utils/system.py new file mode 100644 index 0000000..214f077 --- /dev/null +++ b/src/utils/system.py @@ -0,0 +1,38 @@ +import logging +import os +import subprocess +from typing import Any + +import psutil + + +def get_cpu_temperature() -> Any: + """Выполняет попытку получения температуры CPU. Сначала через psutil, затем через чтение из системного файла.""" + try: + if hasattr(psutil, 'sensors_temperatures'): + temps = psutil.sensors_temperatures() + if 'coretemp' in temps: + return temps['coretemp'][0].current + with open('/sys/class/thermal/thermal_zone0/temp') as f: + return int(f.read()) / 1000 + except Exception as e: + logging.error(f'Не удалось получить температуру CPU: {e}') + return None + + +def execute_command(command: str, current_directory: str) -> tuple[str, str]: + """Выполняет команду в текущей директории. Если команда начинается с "cd", меняет директорию. Возвращает результат выполнения и текущую директорию.""" + try: + if command.startswith('cd '): + new_dir = command.split(' ', 1)[1].strip() + try: + os.chdir(new_dir) + return f'Текущая директория изменена на: {os.getcwd()}', os.getcwd() + except Exception as e: + return f'Ошибка при смене директории: {e}', current_directory + else: + result = subprocess.run(command, shell=True, capture_output=True, text=True, cwd=current_directory, check=True) + return result.stdout or result.stderr, current_directory + except Exception as e: + logging.error(f'Ошибка при выполнении команды: {e}') + return f'Ошибка: {e}', current_directory