Initial commit: Telegram CLI Bot with AI memory, tools and multi-provider support
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сервисы бота."""
|
||||
|
||||
from bot.services.command_executor import (
|
||||
execute_cli_command,
|
||||
_execute_local_command,
|
||||
_execute_ssh_command,
|
||||
_show_result,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"execute_cli_command",
|
||||
"_execute_local_command",
|
||||
"_execute_ssh_command",
|
||||
"_show_result",
|
||||
]
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Сервис выполнения CLI команд (локальных и SSH)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
import asyncssh
|
||||
from telegram import Update
|
||||
|
||||
from bot.config import config, state_manager, server_manager
|
||||
from bot.models.server import Server
|
||||
from bot.models.session import ssh_session_manager, local_session_manager
|
||||
from bot.utils.ssh_readers import read_ssh_output, read_pty_output, detect_input_type
|
||||
from bot.utils.formatters import format_long_output, escape_html, send_long_message
|
||||
from bot.utils.cleaners import clean_ansi_codes, normalize_output
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def execute_cli_command(query, command: str):
|
||||
"""Выполнение CLI команды из кнопки меню."""
|
||||
user_id = query.from_user.id
|
||||
state = state_manager.get(user_id)
|
||||
server_name = state.current_server
|
||||
server = server_manager.get(server_name)
|
||||
|
||||
# Определяем рабочую директорию
|
||||
working_dir = state.working_directory or config.working_directory
|
||||
|
||||
logger.info(f"Выполнение команды: {command} на сервере: {server_name}, в директории: {working_dir}")
|
||||
|
||||
# Если локальный сервер — выполняем локально
|
||||
if server_name == "local" or server is None:
|
||||
await _execute_local_command(query, command, working_dir)
|
||||
else:
|
||||
# Выполняем через SSH
|
||||
await _execute_ssh_command(query, command, server, working_dir)
|
||||
|
||||
|
||||
async def _execute_local_command(query, command: str, working_dir: str):
|
||||
"""Выполнение локальной команды через PTY."""
|
||||
user_id = query.from_user.id
|
||||
|
||||
try:
|
||||
logger.info(f"Создание PTY для команды: {command}")
|
||||
# Создаём PTY
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
logger.info(f"PTY создан: master_fd={master_fd}")
|
||||
|
||||
# Запускаем процесс в PTY
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
# Дочерний процесс
|
||||
os.close(master_fd)
|
||||
os.setsid()
|
||||
os.dup2(slave_fd, 0) # stdin
|
||||
os.dup2(slave_fd, 1) # stdout
|
||||
os.dup2(slave_fd, 2) # stderr
|
||||
os.close(slave_fd)
|
||||
|
||||
os.chdir(working_dir)
|
||||
os.execvp("/bin/bash", ["/bin/bash", "-c", command])
|
||||
else:
|
||||
# Родительский процесс
|
||||
os.close(slave_fd)
|
||||
logger.info(f"Процесс запущен: pid={pid}")
|
||||
|
||||
# Создаём сессию
|
||||
session = local_session_manager.create_session(
|
||||
user_id=user_id,
|
||||
command=command,
|
||||
master_fd=master_fd,
|
||||
pid=pid
|
||||
)
|
||||
|
||||
# Читаем начальный вывод
|
||||
logger.info("Чтение вывода из PTY...")
|
||||
output, is_done = read_pty_output(master_fd, timeout=3.0)
|
||||
logger.info(f"Прочитано: {len(output)} байт, is_done={is_done}")
|
||||
logger.debug(f"Вывод: {output[:500] if output else '(пусто)'}")
|
||||
|
||||
session.output_buffer = output
|
||||
session.last_activity = datetime.now()
|
||||
|
||||
# Проверяем тип ввода
|
||||
input_type = detect_input_type(output)
|
||||
logger.info(f"Тип ввода: {input_type}")
|
||||
|
||||
if input_type == "password":
|
||||
session.waiting_for_input = True
|
||||
session.input_type = "password"
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Требуется ввод**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"**🔐 Запрошен пароль**\n\n"
|
||||
f"```\n{output.strip()[-200:]}\n```\n\n"
|
||||
f"Отправьте пароль в чат:",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
elif input_type == "confirm":
|
||||
session.waiting_for_input = True
|
||||
session.input_type = "confirm"
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Требуется ввод**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"**❓ Требуется подтверждение**\n\n"
|
||||
f"```\n{output.strip()[-200:]}\n```\n\n"
|
||||
f"Отправьте `y` (да) или `n` (нет):",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
elif is_done:
|
||||
local_session_manager.close_session(user_id)
|
||||
await _show_result(query, command, output.encode(), b"", 0)
|
||||
return
|
||||
else:
|
||||
# Команда ещё выполняется
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Выполнение...**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"```\n{output.strip()[-500:] if output else 'Выполняется...'}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
max_iterations = 60 # Максимум 60 итераций (5 минут при timeout=5.0)
|
||||
iteration_count = 0
|
||||
|
||||
while not is_done and iteration_count < max_iterations:
|
||||
more_output, is_done = read_pty_output(master_fd, timeout=5.0)
|
||||
output += more_output
|
||||
session.output_buffer = output
|
||||
session.last_activity = datetime.now()
|
||||
iteration_count += 1
|
||||
|
||||
input_type = detect_input_type(output)
|
||||
if input_type in ("password", "confirm"):
|
||||
session.waiting_for_input = True
|
||||
session.input_type = input_type
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Требуется ввод**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"{'**🔐 Запрошен пароль**' if input_type == 'password' else '**❓ Требуется подтверждение**'}\n\n"
|
||||
f"```\n{output.strip()[-200:]}\n```\n\n"
|
||||
f"{'Отправьте пароль в чат:' if input_type == 'password' else 'Отправьте `y` (да) или `n` (нет):'}",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
|
||||
if iteration_count >= max_iterations:
|
||||
logger.warning(f"Превышено максимальное количество итераций ({max_iterations}) для команды {command}")
|
||||
local_session_manager.close_session(user_id)
|
||||
await _show_result(query, command, output.encode(), "Превышено время выполнения команды".encode(), 1)
|
||||
return
|
||||
|
||||
local_session_manager.close_session(user_id)
|
||||
await _show_result(query, command, output.encode(), b"", 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выполнения команды: {e}")
|
||||
local_session_manager.close_session(user_id)
|
||||
await query.edit_message_text(
|
||||
f"❌ **Ошибка:**\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def _execute_ssh_command(query, command: str, server: Server, working_dir: str):
|
||||
"""Выполнение команды через SSH с интерактивной сессией."""
|
||||
user_id = query.from_user.id
|
||||
|
||||
try:
|
||||
# Подготовка SSH ключа
|
||||
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||
|
||||
# Подготовка параметров подключения
|
||||
connect_kwargs = {
|
||||
"host": server.host,
|
||||
"port": server.port,
|
||||
"username": server.user,
|
||||
"client_host_keys": None,
|
||||
"known_hosts": None
|
||||
}
|
||||
|
||||
# Добавляем ключ или пароль
|
||||
if client_keys:
|
||||
connect_kwargs["client_keys"] = client_keys
|
||||
if server.password:
|
||||
connect_kwargs["password"] = server.password
|
||||
|
||||
logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}")
|
||||
|
||||
# Подключение к серверу
|
||||
conn = await asyncssh.connect(**connect_kwargs)
|
||||
|
||||
# Выполнение команды с cd в рабочую директорию
|
||||
full_command = f"cd {working_dir} && {command}" if working_dir else command
|
||||
|
||||
# Создаем интерактивный процесс с PTY для поддержки ввода
|
||||
# TERM环境变量设置 для корректной кодировки
|
||||
process = await conn.create_process(
|
||||
full_command,
|
||||
term_type='xterm-256color',
|
||||
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
||||
)
|
||||
|
||||
# Создаём сессию
|
||||
session = ssh_session_manager.create_session(
|
||||
user_id=user_id,
|
||||
server=server,
|
||||
working_dir=working_dir,
|
||||
conn=conn,
|
||||
process=process,
|
||||
command=command
|
||||
)
|
||||
|
||||
# Читаем начальный вывод
|
||||
output, is_done = await read_ssh_output(process, timeout=3.0)
|
||||
session.output_buffer = output
|
||||
session.last_activity = datetime.now()
|
||||
|
||||
# Читаем пока процесс не завершится
|
||||
while not is_done:
|
||||
more_output, is_done = await read_ssh_output(process, timeout=2.0)
|
||||
output += more_output
|
||||
session.output_buffer = output
|
||||
session.last_activity = datetime.now()
|
||||
|
||||
# Проверяем тип ввода
|
||||
input_type = detect_input_type(output)
|
||||
|
||||
if input_type == "password":
|
||||
# Запрос пароля
|
||||
session.waiting_for_input = True
|
||||
session.input_type = "password"
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Требуется ввод**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"**🔐 Запрошен пароль**\n\n"
|
||||
f"```\n{output.strip()[-200:]}\n```\n\n"
|
||||
f"Отправьте пароль в чат:",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
elif input_type == "confirm":
|
||||
# Запрос подтверждения
|
||||
session.waiting_for_input = True
|
||||
session.input_type = "confirm"
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"⏳ **Требуется ввод**\n\n"
|
||||
f"Команда: `{command}`\n\n"
|
||||
f"**❓ Требуется подтверждение**\n\n"
|
||||
f"```\n{output.strip()[-200:]}\n```\n\n"
|
||||
f"Отправьте `y` (да) или `n` (нет):",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Команда завершена, показываем результат
|
||||
ssh_session_manager.close_session(user_id)
|
||||
await _show_result(query, command, output.encode(), "", 0)
|
||||
return
|
||||
|
||||
except asyncssh.Error as e:
|
||||
logger.error(f"SSH ошибка: {e}")
|
||||
ssh_session_manager.close_session(user_id)
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"❌ **SSH ошибка:**\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Таймаут SSH подключения")
|
||||
ssh_session_manager.close_session(user_id)
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
"❌ **Таймаут**\n\nКоманда выполнялась дольше 30 секунд и была прервана.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выполнения команды: {e}")
|
||||
ssh_session_manager.close_session(user_id)
|
||||
await query.answer()
|
||||
await query.message.reply_text(
|
||||
f"❌ **Ошибка:**\n```\n{str(e)}\n```",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
async def _show_result(query, command: str, stdout: bytes | str, stderr: bytes | str, returncode: int):
|
||||
"""Показ результата выполнения команды."""
|
||||
# Обрабатываем как bytes так и str
|
||||
if isinstance(stdout, bytes):
|
||||
output = clean_ansi_codes(stdout.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
output = clean_ansi_codes(str(stdout))
|
||||
output = normalize_output(output)
|
||||
|
||||
if isinstance(stderr, bytes):
|
||||
error = clean_ansi_codes(stderr.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
error = clean_ansi_codes(str(stderr))
|
||||
|
||||
result = f"✅ **Результат:**\n\n"
|
||||
|
||||
if output:
|
||||
# Форматируем длинный вывод
|
||||
output = format_long_output(output)
|
||||
result += f"```\n{output}\n```\n"
|
||||
|
||||
if error:
|
||||
result += f"**Ошибки:**\n```\n{error}\n```\n"
|
||||
|
||||
result += f"\n**Код возврата:** `{returncode}`"
|
||||
|
||||
# Экранируем специальные символы Markdown вне блоков кода
|
||||
from bot.utils.formatters import escape_markdown
|
||||
result = escape_markdown(result)
|
||||
|
||||
# Отправляем с разбивкой на части если нужно
|
||||
await send_long_message(query, result, parse_mode="Markdown")
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cron Scheduler - планировщик задач для автоматического выполнения.
|
||||
|
||||
Проверяет задачи каждую минуту и выполняет те, у которых наступило время.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, Awaitable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CronScheduler:
|
||||
"""
|
||||
Планировщик cron-задач.
|
||||
|
||||
Автоматически проверяет задачи и выполняет их через AI-агент.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cron_tool,
|
||||
ai_agent,
|
||||
send_notification: Optional[Callable[[int, str], Awaitable[None]]] = None
|
||||
):
|
||||
"""
|
||||
Инициализировать планировщик.
|
||||
|
||||
Args:
|
||||
cron_tool: Экземпляр CronTool
|
||||
ai_agent: Экземпляр AI-агента для выполнения задач
|
||||
send_notification: Асинхронная функция для отправки уведомлений (user_id, message)
|
||||
"""
|
||||
self.cron_tool = cron_tool
|
||||
self.ai_agent = ai_agent
|
||||
self.send_notification = send_notification
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._check_interval = 60 # Проверка каждую минуту
|
||||
|
||||
async def start(self):
|
||||
"""Запустить планировщик в фоновом режиме."""
|
||||
if self._running:
|
||||
logger.warning("Планировщик уже запущен")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("🕐 Планировщик cron-задач запущен")
|
||||
|
||||
async def stop(self):
|
||||
"""Остановить планировщик."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("🕐 Планировщик cron-задач остановлен")
|
||||
|
||||
async def _run_loop(self):
|
||||
"""Основной цикл планировщика."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._check_and_run_tasks()
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка в цикле планировщика: {e}")
|
||||
|
||||
await asyncio.sleep(self._check_interval)
|
||||
|
||||
async def _check_and_run_tasks(self):
|
||||
"""Проверить задачи и выполнить те, у которых наступило время."""
|
||||
now = datetime.now()
|
||||
logger.debug(f"🕐 Проверка задач на {now.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
# Получаем список всех задач
|
||||
result = await self.cron_tool.list_jobs()
|
||||
|
||||
if not result.success:
|
||||
logger.error(f"Ошибка получения списка задач: {result.error}")
|
||||
return
|
||||
|
||||
jobs = result.data
|
||||
executed_count = 0
|
||||
|
||||
for job in jobs:
|
||||
if not job.get('enabled'):
|
||||
continue
|
||||
|
||||
next_run_str = job.get('next_run')
|
||||
if not next_run_str:
|
||||
continue
|
||||
|
||||
try:
|
||||
next_run = datetime.strptime(next_run_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
logger.error(f"Ошибка парсинга next_run для задачи {job['id']}: {next_run_str}")
|
||||
continue
|
||||
|
||||
# Если время пришло
|
||||
if now >= next_run:
|
||||
# ЗАЩИТА ОТ DUPLICATE: проверяем last_run
|
||||
last_run_str = job.get('last_run')
|
||||
if last_run_str:
|
||||
try:
|
||||
last_run = datetime.strptime(last_run_str, '%Y-%m-%d %H:%M:%S')
|
||||
# Если задача уже выполнялась в этом окне (менее минуты назад) - пропускаем
|
||||
if (now - last_run).total_seconds() < 60:
|
||||
logger.debug(f"⏭️ Задача #{job['id']} уже выполнена в этом окне, пропускаем")
|
||||
continue
|
||||
except ValueError:
|
||||
pass # Игнорируем ошибку парсинга last_run
|
||||
|
||||
logger.info(f"⏰ Время задачи #{job['id']}: {job['name']}")
|
||||
await self._execute_job(job)
|
||||
executed_count += 1
|
||||
|
||||
if executed_count > 0:
|
||||
logger.info(f"✅ Выполнено задач: {executed_count}")
|
||||
|
||||
async def _execute_job(self, job: dict):
|
||||
"""
|
||||
Выполнить задачу.
|
||||
|
||||
Args:
|
||||
job: Словарь с данными задачи
|
||||
"""
|
||||
job_id = job['id']
|
||||
job_name = job['name']
|
||||
notify = job.get('notify', False)
|
||||
log_results = job.get('log_results', True)
|
||||
user_id = job.get('user_id') # ID пользователя который создал задачу
|
||||
schedule = job.get('schedule', '')
|
||||
|
||||
# Выполняем задачу через AI-агент
|
||||
result = await self.cron_tool.run_job(
|
||||
job_id=job_id,
|
||||
ai_agent=self.ai_agent,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if result.success:
|
||||
logger.info(f"✅ Задача '{job_name}' выполнена успешно")
|
||||
|
||||
# ПЕРЕСЧЁТ NEXT_RUN: обновляем время следующего выполнения
|
||||
await self.cron_tool.update_next_run(job_id)
|
||||
|
||||
# Отправляем уведомление если нужно
|
||||
if notify and self.send_notification and user_id:
|
||||
result_text = result.metadata.get('result_text', 'Задача выполнена')
|
||||
await self.send_notification(user_id, result_text)
|
||||
else:
|
||||
logger.error(f"❌ Задача '{job_name}' не выполнена: {result.error}")
|
||||
|
||||
if notify and self.send_notification and user_id:
|
||||
await self.send_notification(
|
||||
user_id,
|
||||
f"❌ **Ошибка задачи '{job_name}':**\n{result.error}"
|
||||
)
|
||||
|
||||
def set_notification_callback(self, callback: Callable[[int, str], Awaitable[None]]):
|
||||
"""Установить callback для отправки уведомлений."""
|
||||
self.send_notification = callback
|
||||
|
||||
|
||||
# Глобальный планировщик
|
||||
scheduler: Optional[CronScheduler] = None
|
||||
|
||||
|
||||
def init_scheduler(cron_tool, ai_agent, send_notification=None) -> CronScheduler:
|
||||
"""Инициализировать глобальный планировщик."""
|
||||
global scheduler
|
||||
scheduler = CronScheduler(cron_tool, ai_agent, send_notification)
|
||||
return scheduler
|
||||
|
||||
|
||||
def get_scheduler() -> Optional[CronScheduler]:
|
||||
"""Получить глобальный планировщик."""
|
||||
return scheduler
|
||||
Reference in New Issue
Block a user