Версия 0.8.0 - Исправление SSH и команда /restart_bot
Основные изменения: - Исправлено чтение вывода SSH команд (wait_and_read_ssh вместо цикла с таймаутом) - Добавлена команда /restart_bot для перезапуска бота через sudo - Пароль sudo запрашивается у пользователя (ИИ отключается на время ввода) - После перезапуска бот отправляет уведомление с главным меню - Улучшена обработка stdout/stderr в SSH инструменте Исправленные проблемы: - SSH команды не возвращали вывод (returncode был None до завершения процесса) - Использован подход с параллельным чтением потоков и process.wait() - Команда /restart_bot использует script для создания PTY Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
02971d83ef
commit
9f906af400
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Бот для выполнения CLI команд на вашем ПК через Telegram с многоуровневым меню и гибкой настройкой.
|
Бот для выполнения CLI команд на вашем ПК через Telegram с многоуровневым меню и гибкой настройкой.
|
||||||
|
|
||||||
**Версия:** 0.7.0
|
**Версия:** 0.8.0
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
|
|
|
||||||
209
bot.py
209
bot.py
|
|
@ -74,7 +74,7 @@ from bot.models.server import Server
|
||||||
from bot.models.session import SSHSession, SSHSessionManager, LocalSession, LocalSessionManager, INPUT_PATTERNS
|
from bot.models.session import SSHSession, SSHSessionManager, LocalSession, LocalSessionManager, INPUT_PATTERNS
|
||||||
from bot.utils.cleaners import clean_ansi_codes, normalize_output
|
from bot.utils.cleaners import clean_ansi_codes, normalize_output
|
||||||
from bot.utils.formatters import escape_markdown, split_message, send_long_message, format_long_output, MAX_MESSAGE_LENGTH
|
from bot.utils.formatters import escape_markdown, split_message, send_long_message, format_long_output, MAX_MESSAGE_LENGTH
|
||||||
from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_output
|
from bot.utils.ssh_readers import detect_input_type, read_ssh_output, read_pty_output, wait_and_read_ssh
|
||||||
from bot.utils.decorators import check_access
|
from bot.utils.decorators import check_access
|
||||||
from bot.keyboards.menus import MenuItem, init_menus
|
from bot.keyboards.menus import MenuItem, init_menus
|
||||||
|
|
||||||
|
|
@ -133,6 +133,11 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
await handle_local_session_input(update, text, local_session)
|
await handle_local_session_input(update, text, local_session)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Проверка: не ждём ли пароль для перезапуска бота
|
||||||
|
if state.waiting_for_restart_password:
|
||||||
|
await handle_restart_password(update, text)
|
||||||
|
return
|
||||||
|
|
||||||
# ПРОВЕРКА: режим чата с ИИ агентом
|
# ПРОВЕРКА: режим чата с ИИ агентом
|
||||||
if state.ai_chat_mode:
|
if state.ai_chat_mode:
|
||||||
logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}")
|
logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}")
|
||||||
|
|
@ -663,7 +668,7 @@ async def format_tool_result(tool_name: str, result: 'ToolResult') -> str:
|
||||||
|
|
||||||
return f"RSS: {result.data}"
|
return f"RSS: {result.data}"
|
||||||
|
|
||||||
elif tool_name == 'ssh_executor':
|
elif tool_name == 'ssh_tool':
|
||||||
if not result.success:
|
if not result.success:
|
||||||
return f"❌ **Ошибка SSH:**\n```\n{result.error}\n```"
|
return f"❌ **Ошибка SSH:**\n```\n{result.error}\n```"
|
||||||
|
|
||||||
|
|
@ -1288,10 +1293,10 @@ async def _execute_composite_command_ssh(update: Update, command: str, server: S
|
||||||
logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}")
|
logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}")
|
||||||
|
|
||||||
conn = await asyncssh.connect(**connect_kwargs)
|
conn = await asyncssh.connect(**connect_kwargs)
|
||||||
|
|
||||||
# Выполнение команды с cd в рабочую директорию
|
# Выполнение команды с cd в рабочую директорию
|
||||||
full_command = f"cd {working_dir} && {command_with_pwd}" if working_dir else command_with_pwd
|
full_command = f"cd {working_dir} && {command_with_pwd}" if working_dir else command_with_pwd
|
||||||
|
|
||||||
# Создаем интерактивный процесс с PTY для поддержки ввода
|
# Создаем интерактивный процесс с PTY для поддержки ввода
|
||||||
# TERM环境变量设置 для корректной кодировки
|
# TERM环境变量设置 для корректной кодировки
|
||||||
process = await conn.create_process(
|
process = await conn.create_process(
|
||||||
|
|
@ -1299,7 +1304,7 @@ async def _execute_composite_command_ssh(update: Update, command: str, server: S
|
||||||
term_type='xterm-256color',
|
term_type='xterm-256color',
|
||||||
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаём сессию
|
# Создаём сессию
|
||||||
session = ssh_session_manager.create_session(
|
session = ssh_session_manager.create_session(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -1309,19 +1314,13 @@ async def _execute_composite_command_ssh(update: Update, command: str, server: S
|
||||||
process=process,
|
process=process,
|
||||||
command=command
|
command=command
|
||||||
)
|
)
|
||||||
|
|
||||||
# Читаем начальный вывод
|
# Читаем вывод с ожиданием завершения процесса
|
||||||
output, is_done = await read_ssh_output(process, timeout=3.0)
|
# wait_and_read_ssh решает проблему с returncode, который доступен только после завершения
|
||||||
|
output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0)
|
||||||
session.output_buffer = output
|
session.output_buffer = output
|
||||||
session.last_activity = datetime.now()
|
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)
|
input_type = detect_input_type(output)
|
||||||
|
|
||||||
|
|
@ -1359,7 +1358,7 @@ async def _execute_composite_command_ssh(update: Update, command: str, server: S
|
||||||
output = '\n'.join(lines[:-1])
|
output = '\n'.join(lines[:-1])
|
||||||
|
|
||||||
ssh_session_manager.close_session(user_id)
|
ssh_session_manager.close_session(user_id)
|
||||||
await _show_result_message(update, command, output, "", 0)
|
await _show_result_message(update, command, output, error_output, returncode)
|
||||||
return
|
return
|
||||||
|
|
||||||
except asyncssh.Error as e:
|
except asyncssh.Error as e:
|
||||||
|
|
@ -1520,7 +1519,7 @@ async def _execute_local_command_interactive(update: Update, command: str, worki
|
||||||
async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str):
|
async def _execute_ssh_command_message(update: Update, command: str, server: Server, working_dir: str):
|
||||||
"""Выполнение команды через SSH из сообщения с интерактивной сессией."""
|
"""Выполнение команды через SSH из сообщения с интерактивной сессией."""
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
client_keys = [server_manager.ssh_key_path] if server_manager.ssh_key_path else None
|
||||||
|
|
||||||
|
|
@ -1541,10 +1540,10 @@ async def _execute_ssh_command_message(update: Update, command: str, server: Ser
|
||||||
logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}")
|
logger.info(f"SSH подключение к {server.host}:{server.port} как {server.user}")
|
||||||
|
|
||||||
conn = await asyncssh.connect(**connect_kwargs)
|
conn = await asyncssh.connect(**connect_kwargs)
|
||||||
|
|
||||||
# Выполнение команды с cd в рабочую директорию
|
# Выполнение команды с cd в рабочую директорию
|
||||||
full_command = f"cd {working_dir} && {command}" if working_dir else command
|
full_command = f"cd {working_dir} && {command}" if working_dir else command
|
||||||
|
|
||||||
# Создаем интерактивный процесс с PTY для поддержки ввода
|
# Создаем интерактивный процесс с PTY для поддержки ввода
|
||||||
# TERM环境变量设置 для корректной кодировки
|
# TERM环境变量设置 для корректной кодировки
|
||||||
process = await conn.create_process(
|
process = await conn.create_process(
|
||||||
|
|
@ -1552,7 +1551,7 @@ async def _execute_ssh_command_message(update: Update, command: str, server: Ser
|
||||||
term_type='xterm-256color',
|
term_type='xterm-256color',
|
||||||
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаём сессию
|
# Создаём сессию
|
||||||
session = ssh_session_manager.create_session(
|
session = ssh_session_manager.create_session(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -1562,19 +1561,13 @@ async def _execute_ssh_command_message(update: Update, command: str, server: Ser
|
||||||
process=process,
|
process=process,
|
||||||
command=command
|
command=command
|
||||||
)
|
)
|
||||||
|
|
||||||
# Читаем начальный вывод
|
# Читаем вывод с ожиданием завершения процесса
|
||||||
output, is_done = await read_ssh_output(process, timeout=3.0)
|
# wait_and_read_ssh решает проблему с returncode, который доступен только после завершения
|
||||||
|
output, error_output, returncode = await wait_and_read_ssh(process, timeout=30.0)
|
||||||
session.output_buffer = output
|
session.output_buffer = output
|
||||||
session.last_activity = datetime.now()
|
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)
|
input_type = detect_input_type(output)
|
||||||
|
|
||||||
|
|
@ -1605,7 +1598,7 @@ async def _execute_ssh_command_message(update: Update, command: str, server: Ser
|
||||||
else:
|
else:
|
||||||
# Команда завершена, показываем результат
|
# Команда завершена, показываем результат
|
||||||
ssh_session_manager.close_session(user_id)
|
ssh_session_manager.close_session(user_id)
|
||||||
await _show_result_message(update, command, output, "", 0)
|
await _show_result_message(update, command, output, error_output, returncode)
|
||||||
return
|
return
|
||||||
|
|
||||||
except asyncssh.Error as e:
|
except asyncssh.Error as e:
|
||||||
|
|
@ -1718,6 +1711,7 @@ async def post_init(application: Application):
|
||||||
BotCommand("settings", "Настройки"),
|
BotCommand("settings", "Настройки"),
|
||||||
BotCommand("cron", "Управление задачами"),
|
BotCommand("cron", "Управление задачами"),
|
||||||
BotCommand("stop", "Прервать SSH-сессию"),
|
BotCommand("stop", "Прервать SSH-сессию"),
|
||||||
|
BotCommand("restart_bot", "Перезапустить бота"),
|
||||||
BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"),
|
BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"),
|
||||||
BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"),
|
BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"),
|
||||||
BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"),
|
BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"),
|
||||||
|
|
@ -1746,6 +1740,49 @@ async def post_init(application: Application):
|
||||||
logger.warning("⚠️ Cron инструмент не найден, планировщик не запущен")
|
logger.warning("⚠️ Cron инструмент не найден, планировщик не запущен")
|
||||||
|
|
||||||
logger.info("Бот инициализирован")
|
logger.info("Бот инициализирован")
|
||||||
|
|
||||||
|
# Проверяем, был ли запрошен перезапуск пользователем
|
||||||
|
await check_restart_and_notify(application)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_restart_and_notify(application):
|
||||||
|
"""Проверить файл перезапуска и отправить уведомление пользователю."""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
restart_file = "/tmp/telegram_bot_restart.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(restart_file):
|
||||||
|
with open(restart_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
user_id = data.get('user_id')
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
logger.info(f"📢 Отправка уведомления о запуске пользователю {user_id}")
|
||||||
|
|
||||||
|
# Получаем состояние пользователя
|
||||||
|
state = state_manager.get(user_id)
|
||||||
|
|
||||||
|
# Показываем текущую директорию и сервер
|
||||||
|
working_dir = state.working_directory or config.working_directory
|
||||||
|
server = server_manager.get(state.current_server)
|
||||||
|
server_desc = server.description if server else state.current_server
|
||||||
|
|
||||||
|
# Отправляем сообщение напрямую
|
||||||
|
await application.bot.send_message(
|
||||||
|
chat_id=user_id,
|
||||||
|
text=f"✅ Бот перезапущен!\n\n🖥️ Сервер: {server_desc}\n📁 Директория: {working_dir}\n\nГотов к работе! Отправьте команду или выберите действие в меню:",
|
||||||
|
reply_markup=menu_builder.get_keyboard("main", user_id=user_id, state=state)
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
|
||||||
|
|
||||||
|
# Удаляем файл
|
||||||
|
os.remove(restart_file)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке уведомления о перезапуске: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def send_cron_notification(user_id: int, message: str):
|
async def send_cron_notification(user_id: int, message: str):
|
||||||
|
|
@ -1797,6 +1834,115 @@ async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# КОМАНДА ПЕРЕЗАПУСКА БОТА
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@check_access
|
||||||
|
async def restart_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Обработка команды /restart_bot - перезапуск бота через systemctl."""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
state = state_manager.get(user_id)
|
||||||
|
|
||||||
|
# Устанавливаем флаг ожидания пароля
|
||||||
|
state.waiting_for_restart_password = True
|
||||||
|
|
||||||
|
# Отключаем ИИ на время ввода пароля
|
||||||
|
state.ai_chat_mode = False
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
"🔄 **Перезапуск бота**\n\n"
|
||||||
|
"Для перезапуска требуется ввести пароль `sudo`.\n\n"
|
||||||
|
"🔐 *Отправьте пароль в чат:*\n\n"
|
||||||
|
"_После ввода пароль будет использован для команды:_\n"
|
||||||
|
"`sudo systemctl restart telegram-bot`\n\n"
|
||||||
|
"⚠️ *Бот будет перезапущен, соединение прервётся.*",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_restart_password(update: Update, text: str):
|
||||||
|
"""Обработка пароля для перезапуска бота."""
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
state = state_manager.get(user_id)
|
||||||
|
password = text.strip()
|
||||||
|
|
||||||
|
logger.info(f"Пользователь {user_id} ввёл пароль для перезапуска бота")
|
||||||
|
|
||||||
|
# Сбрасываем флаг
|
||||||
|
state.waiting_for_restart_password = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Сохраняем user_id в файл для уведомления после перезапуска
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
restart_file = "/tmp/telegram_bot_restart.json"
|
||||||
|
with open(restart_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({'user_id': user_id}, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Отправляем сообщение о начале перезапуска
|
||||||
|
await update.message.reply_text(
|
||||||
|
"⏳ *Выполнение перезапуска...*\n\n"
|
||||||
|
f"Пароль принят, выполняю команду.\n\n"
|
||||||
|
"_Бот будет недоступен несколько секунд._",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаём временный скрипт с паролем
|
||||||
|
import tempfile
|
||||||
|
script_file = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False)
|
||||||
|
# Используем script для создания псевдо-терминала
|
||||||
|
script_file.write(f"""#!/bin/bash
|
||||||
|
printf '%s\\n' '{password}' | sudo -S systemctl restart telegram-bot
|
||||||
|
""")
|
||||||
|
script_file.close()
|
||||||
|
os.chmod(script_file.name, 0o755)
|
||||||
|
|
||||||
|
# Запускаем через script для PTY
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
'script', '-q', '-c', f'/bin/bash {script_file.name}', '/dev/null',
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15)
|
||||||
|
|
||||||
|
# Удаляем скрипт
|
||||||
|
os.remove(script_file.name)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
logger.info(f"Бот успешно перезапущен пользователем {user_id}")
|
||||||
|
else:
|
||||||
|
error_msg = stderr.decode('utf-8', errors='replace').strip()
|
||||||
|
logger.error(f"Ошибка перезапуска: {error_msg}")
|
||||||
|
# Удаляем файл если ошибка
|
||||||
|
if os.path.exists(restart_file):
|
||||||
|
os.remove(restart_file)
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"❌ *Ошибка перезапуска:*\n```\n{error_msg}\n```",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("Таймаут при перезапуске бота")
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ *Ошибка*\n\n"
|
||||||
|
"Таймаут выполнения команды перезапуска.",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
if os.path.exists("/tmp/telegram_bot_restart.json"):
|
||||||
|
os.remove("/tmp/telegram_bot_restart.json")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Ошибка при перезапуске бота: {e}")
|
||||||
|
await update.message.reply_text(
|
||||||
|
"❌ *Ошибка*\n\n"
|
||||||
|
f"```\n{str(e)}\n```",
|
||||||
|
parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
if os.path.exists("/tmp/telegram_bot_restart.json"):
|
||||||
|
os.remove("/tmp/telegram_bot_restart.json")
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# КОМАНДЫ ДЛЯ РАБОТЫ С QWEN CODE (ИИ)
|
# КОМАНДЫ ДЛЯ РАБОТЫ С QWEN CODE (ИИ)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
@ -2124,6 +2270,7 @@ def main():
|
||||||
application.add_handler(CommandHandler("rss", rss_command))
|
application.add_handler(CommandHandler("rss", rss_command))
|
||||||
application.add_handler(CommandHandler("menu", menu_command))
|
application.add_handler(CommandHandler("menu", menu_command))
|
||||||
application.add_handler(CommandHandler("stop", stop_command))
|
application.add_handler(CommandHandler("stop", stop_command))
|
||||||
|
application.add_handler(CommandHandler("restart_bot", restart_bot_command))
|
||||||
application.add_handler(CommandHandler("memory", memory_command))
|
application.add_handler(CommandHandler("memory", memory_command))
|
||||||
application.add_handler(CommandHandler("compact", compact_command))
|
application.add_handler(CommandHandler("compact", compact_command))
|
||||||
application.add_handler(CommandHandler("facts", facts_command))
|
application.add_handler(CommandHandler("facts", facts_command))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ AI Agent Module - автономный агент с инструментами.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -97,8 +98,11 @@ class AIAgent:
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# Прямые триггеры — высокий приоритет
|
# Прямые триггеры — высокий приоритет
|
||||||
|
# Используем паттерн с границами для избежания частичных совпадений
|
||||||
for trigger in self.SEARCH_TRIGGERS:
|
for trigger in self.SEARCH_TRIGGERS:
|
||||||
if trigger in message_lower:
|
escaped_trigger = re.escape(trigger)
|
||||||
|
pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])'
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
return True, 0.9
|
return True, 0.9
|
||||||
|
|
||||||
# Вопросы с "что", "как", "где", "когда" о внешних фактах
|
# Вопросы с "что", "как", "где", "когда" о внешних фактах
|
||||||
|
|
@ -121,7 +125,7 @@ class AIAgent:
|
||||||
|
|
||||||
def _should_read_rss(self, message: str) -> tuple[bool, float]:
|
def _should_read_rss(self, message: str) -> tuple[bool, float]:
|
||||||
"""Проверить, нужно ли читать RSS ленты.
|
"""Проверить, нужно ли читать RSS ленты.
|
||||||
|
|
||||||
ВАЖНО: Используем ТОЛЬКО полные фразы-триггеры.
|
ВАЖНО: Используем ТОЛЬКО полные фразы-триггеры.
|
||||||
Отдельные слова (типа "новости") НЕ активируют RSS — это предотвращает
|
Отдельные слова (типа "новости") НЕ активируют RSS — это предотвращает
|
||||||
ложные срабатывания когда пользователь просто упоминает слово в контексте.
|
ложные срабатывания когда пользователь просто упоминает слово в контексте.
|
||||||
|
|
@ -129,8 +133,13 @@ class AIAgent:
|
||||||
message_lower = message.lower()
|
message_lower = message.lower()
|
||||||
|
|
||||||
# Только прямые фразы-триггеры — высокий порог
|
# Только прямые фразы-триггеры — высокий порог
|
||||||
|
# Проверяем чтобы триггер был словом/фразой в контексте, а не частью слова
|
||||||
for trigger in self.RSS_TRIGGERS:
|
for trigger in self.RSS_TRIGGERS:
|
||||||
if trigger in message_lower:
|
escaped_trigger = re.escape(trigger)
|
||||||
|
# Паттерн: начало строки ИЛИ пробел/знак препинания перед триггером,
|
||||||
|
# и конец строки ИЛИ пробел/знак препинания после
|
||||||
|
pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])'
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
return True, 0.95
|
return True, 0.95
|
||||||
|
|
||||||
# Отдельные ключевые слова НЕ проверяем — только явные фразы!
|
# Отдельные ключевые слова НЕ проверяем — только явные фразы!
|
||||||
|
|
@ -147,8 +156,11 @@ class AIAgent:
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# Прямые триггеры
|
# Прямые триггеры
|
||||||
|
# Используем паттерн с границами для избежания частичных совпадений
|
||||||
for trigger in self.SSH_TRIGGERS:
|
for trigger in self.SSH_TRIGGERS:
|
||||||
if trigger in message_lower:
|
escaped_trigger = re.escape(trigger)
|
||||||
|
pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])'
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
return True, 0.9
|
return True, 0.9
|
||||||
|
|
||||||
# Команды системного администрирования
|
# Команды системного администрирования
|
||||||
|
|
@ -175,8 +187,11 @@ class AIAgent:
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# Прямые триггеры
|
# Прямые триггеры
|
||||||
|
# Используем паттерн с границами для избежания частичных совпадений
|
||||||
for trigger in self.CRON_TRIGGERS:
|
for trigger in self.CRON_TRIGGERS:
|
||||||
if trigger in message_lower:
|
escaped_trigger = re.escape(trigger)
|
||||||
|
pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])'
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
return True, 0.85
|
return True, 0.85
|
||||||
|
|
||||||
# Расписания
|
# Расписания
|
||||||
|
|
@ -197,8 +212,11 @@ class AIAgent:
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# Прямые триггеры
|
# Прямые триггеры
|
||||||
|
# Используем паттерн с границами для избежания частичных совпадений
|
||||||
for trigger in self.FILE_SYSTEM_TRIGGERS:
|
for trigger in self.FILE_SYSTEM_TRIGGERS:
|
||||||
if trigger in message_lower:
|
escaped_trigger = re.escape(trigger)
|
||||||
|
pattern = rf'(?:^|[\s,\.!?;:])({escaped_trigger})(?:$|[\s,\.!?;:])'
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
return True, 0.9
|
return True, 0.9
|
||||||
|
|
||||||
# Операции с файлами
|
# Операции с файлами
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ class UserState:
|
||||||
output_next_index: Optional[int] = None # Индекс следующего сообщения для отправки
|
output_next_index: Optional[int] = None # Индекс следующего сообщения для отправки
|
||||||
output_text: Optional[str] = None # Текст для продолжения отправки
|
output_text: Optional[str] = None # Текст для продолжения отправки
|
||||||
output_parse_mode: Optional[str] = None # Parse mode для продолжения
|
output_parse_mode: Optional[str] = None # Parse mode для продолжения
|
||||||
|
|
||||||
|
# Для команды /restart_bot
|
||||||
|
waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска
|
||||||
|
|
||||||
|
|
||||||
class StateManager:
|
class StateManager:
|
||||||
|
|
|
||||||
|
|
@ -50,31 +50,31 @@ class SSHExecutorTool(BaseTool):
|
||||||
self.servers: Dict[str, ServerConfig] = {}
|
self.servers: Dict[str, ServerConfig] = {}
|
||||||
self._last_connection: Optional[asyncssh.SSHClientConnection] = None
|
self._last_connection: Optional[asyncssh.SSHClientConnection] = None
|
||||||
self._last_server: Optional[str] = None
|
self._last_server: Optional[str] = None
|
||||||
|
|
||||||
self._load_servers_from_env()
|
self._load_servers_from_env()
|
||||||
|
|
||||||
def _load_servers_from_env(self):
|
def _load_servers_from_env(self):
|
||||||
"""
|
"""
|
||||||
Загрузить конфигурацию серверов из .env.
|
Загрузить конфигурацию серверов из .env.
|
||||||
|
|
||||||
Формат в .env:
|
Формат в .env:
|
||||||
SERVERS=name|host|port|user|tag|password
|
SERVERS=name|host|port|user|tag|password
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22
|
SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22
|
||||||
"""
|
"""
|
||||||
servers_str = os.getenv('SERVERS', '')
|
servers_str = os.getenv('SERVERS', '')
|
||||||
|
|
||||||
if not servers_str.strip():
|
if not servers_str.strip():
|
||||||
logger.warning("SERVERS не найден в .env, SSH инструмент не будет работать")
|
logger.warning("SERVERS не найден в .env, SSH инструмент не будет работать")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Парсим формат: name|host|port|user|tag|password
|
# Парсим формат: name|host|port|user|tag|password
|
||||||
parts = servers_str.strip().split('|')
|
parts = servers_str.strip().split('|')
|
||||||
|
|
||||||
if len(parts) >= 6:
|
if len(parts) >= 6:
|
||||||
name, host, port, user, tag, password = parts[:6]
|
name, host, port, user, tag, password = parts[:6]
|
||||||
|
|
||||||
self.servers[name.strip()] = ServerConfig(
|
self.servers[name.strip()] = ServerConfig(
|
||||||
host=host.strip(),
|
host=host.strip(),
|
||||||
port=int(port.strip()),
|
port=int(port.strip()),
|
||||||
|
|
@ -89,17 +89,38 @@ class SSHExecutorTool(BaseTool):
|
||||||
|
|
||||||
async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection:
|
async def _connect(self, server_name: str = 'home') -> asyncssh.SSHClientConnection:
|
||||||
"""Подключиться к серверу."""
|
"""Подключиться к серверу."""
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Запрос подключения: server_name='{server_name}'")
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Доступные серверы: {list(self.servers.keys())}")
|
||||||
|
|
||||||
if server_name not in self.servers:
|
if server_name not in self.servers:
|
||||||
|
logger.error(f"❌ [SSH._connect] Сервер '{server_name}' не найден!")
|
||||||
raise ValueError(f"Сервер '{server_name}' не найден. Доступные: {list(self.servers.keys())}")
|
raise ValueError(f"Сервер '{server_name}' не найден. Доступные: {list(self.servers.keys())}")
|
||||||
|
|
||||||
config = self.servers[server_name]
|
config = self.servers[server_name]
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Конфигурация сервера {server_name}:")
|
||||||
|
logger.debug(f" host={config.host}, port={config.port}, username={config.username}")
|
||||||
|
logger.debug(f" password={'***' if config.password else 'None'}, client_keys={config.client_keys}")
|
||||||
|
|
||||||
# Проверяем существующее подключение
|
# Проверяем существующее подключение
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Проверка существующего подключения:")
|
||||||
|
logger.debug(f" _last_connection={self._last_connection}")
|
||||||
|
logger.debug(f" _last_server={self._last_server}")
|
||||||
|
|
||||||
if self._last_connection and self._last_server == server_name:
|
if self._last_connection and self._last_server == server_name:
|
||||||
if not self._last_connection.is_connected():
|
logger.debug(f"🔍 [SSH._connect] Найдено существующее подключение, проверка статуса...")
|
||||||
|
try:
|
||||||
|
# Проверяем transport для проверки активности подключения
|
||||||
|
if self._last_connection.transport is None or not self._last_connection.transport.is_active():
|
||||||
|
logger.debug(f"⚠️ [SSH._connect] Подключение не активно, будет создано новое")
|
||||||
|
self._last_connection = None
|
||||||
|
else:
|
||||||
|
logger.debug(f"✅ [SSH._connect] Используем существующее активное подключение")
|
||||||
|
return self._last_connection
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"⚠️ [SSH._connect] Ошибка проверки подключения: {e}, создаём новое")
|
||||||
self._last_connection = None
|
self._last_connection = None
|
||||||
else:
|
else:
|
||||||
return self._last_connection
|
logger.debug(f"ℹ️ [SSH._connect] Существующего подключения нет, создаём новое")
|
||||||
|
|
||||||
logger.info(f"Подключение к серверу {server_name} ({config.host})")
|
logger.info(f"Подключение к серверу {server_name} ({config.host})")
|
||||||
|
|
||||||
|
|
@ -114,18 +135,24 @@ class SSHExecutorTool(BaseTool):
|
||||||
|
|
||||||
if config.password:
|
if config.password:
|
||||||
connect_kwargs['password'] = config.password
|
connect_kwargs['password'] = config.password
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Используем парольную аутентификацию")
|
||||||
|
|
||||||
if config.client_keys:
|
if config.client_keys:
|
||||||
connect_kwargs['client_keys'] = config.client_keys
|
connect_kwargs['client_keys'] = config.client_keys
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Используем ключевую аутентификацию: {config.client_keys}")
|
||||||
|
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Вызов asyncssh.connect с параметрами: {connect_kwargs.keys()}")
|
||||||
|
|
||||||
self._last_connection = await asyncssh.connect(**connect_kwargs)
|
self._last_connection = await asyncssh.connect(**connect_kwargs)
|
||||||
self._last_server = server_name
|
self._last_server = server_name
|
||||||
logger.info(f"✅ Подключено к {server_name}")
|
logger.info(f"✅ Подключено к {server_name}")
|
||||||
|
logger.debug(f"🔍 [SSH._connect] Подключение успешно: {self._last_connection}")
|
||||||
|
|
||||||
return self._last_connection
|
return self._last_connection
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка подключения к {server_name}: {e}")
|
logger.error(f"❌ [SSH._connect] Ошибка подключения к {server_name}: {e}")
|
||||||
|
logger.exception(f"🔍 [SSH._connect] Exception details:")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def execute_command(
|
async def execute_command(
|
||||||
|
|
@ -145,27 +172,79 @@ class SSHExecutorTool(BaseTool):
|
||||||
Returns:
|
Returns:
|
||||||
Dict с полями: stdout, stderr, returncode, exit_status
|
Dict с полями: stdout, stderr, returncode, exit_status
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] START: server={server}, command={command[:50]}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Вызов _connect(server='{server}')")
|
||||||
conn = await self._connect(server)
|
conn = await self._connect(server)
|
||||||
|
logger.debug(f"✅ [SSH.execute_command] Подключение успешно: {conn}")
|
||||||
|
|
||||||
logger.info(f"Выполнение команды на {server}: {command}")
|
logger.info(f"Выполнение команды на {server}: {command}")
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Создание процесса с командой: {command}")
|
||||||
|
|
||||||
result = await asyncio.wait_for(
|
# Используем create_process для корректной работы с shell-командами
|
||||||
conn.run(command, check=False),
|
process = await conn.create_process(
|
||||||
timeout=timeout
|
command,
|
||||||
|
term_type='xterm-256color',
|
||||||
|
env={'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
|
||||||
)
|
)
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Процесс создан: {process}")
|
||||||
|
|
||||||
|
# Читаем вывод с таймаутом
|
||||||
|
output = ""
|
||||||
|
error_output = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Чтение stdout (timeout={timeout})")
|
||||||
|
# Читаем stdout
|
||||||
|
stdout_data = await asyncio.wait_for(
|
||||||
|
process.stdout.read(),
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
output = stdout_data.strip() if stdout_data else ''
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] stdout получен: {len(output)} bytes")
|
||||||
|
|
||||||
|
# Читаем stderr
|
||||||
|
try:
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Чтение stderr (timeout={timeout//2})")
|
||||||
|
stderr_data = await asyncio.wait_for(
|
||||||
|
process.stderr.read(),
|
||||||
|
timeout=timeout // 2
|
||||||
|
)
|
||||||
|
error_output = stderr_data.strip() if stderr_data else ''
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] stderr получен: {len(error_output)} bytes")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug(f"⚠️ [SSH.execute_command] Таймаут чтения stderr")
|
||||||
|
pass
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"🔍 [SSH.execute_command] Таймаут выполнения команды: {command}")
|
||||||
|
return {
|
||||||
|
'stdout': '',
|
||||||
|
'stderr': f'Таймаут выполнения команды ({timeout} сек)',
|
||||||
|
'returncode': -1,
|
||||||
|
'exit_status': 'timeout',
|
||||||
|
'server': server,
|
||||||
|
'command': command
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"🔍 [SSH.execute_command] Ожидание завершения процесса (returncode)")
|
||||||
|
# Ждём завершения процесса и получаем код возврата
|
||||||
|
returncode = await process.wait()
|
||||||
|
logger.debug(f"✅ [SSH.execute_command] Процесс завершён, returncode={returncode}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stdout': result.stdout.strip() if result.stdout else '',
|
'stdout': output,
|
||||||
'stderr': result.stderr.strip() if result.stderr else '',
|
'stderr': error_output,
|
||||||
'returncode': result.returncode,
|
'returncode': returncode,
|
||||||
'exit_status': result.exit_status,
|
'exit_status': returncode,
|
||||||
'server': server,
|
'server': server,
|
||||||
'command': command
|
'command': command
|
||||||
}
|
}
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"Таймаут выполнения команды: {command}")
|
logger.error(f"🔍 [SSH.execute_command] asyncio.TimeoutError: {command}")
|
||||||
|
logger.exception(f"🔍 [SSH.execute_command] Timeout details:")
|
||||||
return {
|
return {
|
||||||
'stdout': '',
|
'stdout': '',
|
||||||
'stderr': f'Таймаут выполнения команды ({timeout} сек)',
|
'stderr': f'Таймаут выполнения команды ({timeout} сек)',
|
||||||
|
|
@ -176,7 +255,8 @@ class SSHExecutorTool(BaseTool):
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка выполнения команды: {e}")
|
logger.error(f"❌ [SSH.execute_command] Ошибка выполнения команды: {e}")
|
||||||
|
logger.exception(f"🔍 [SSH.execute_command] Exception details:")
|
||||||
return {
|
return {
|
||||||
'stdout': '',
|
'stdout': '',
|
||||||
'stderr': str(e),
|
'stderr': str(e),
|
||||||
|
|
@ -195,29 +275,37 @@ class SSHExecutorTool(BaseTool):
|
||||||
server: Имя сервера (default: первый из .env)
|
server: Имя сервера (default: первый из .env)
|
||||||
timeout: Таймаут в секундах (default: 30)
|
timeout: Таймаут в секундах (default: 30)
|
||||||
"""
|
"""
|
||||||
|
logger.debug(f"🔍 [SSH.execute] ВЫЗОВ: command={command[:50]}..., server={server}, timeout={timeout}")
|
||||||
|
|
||||||
if not command or not command.strip():
|
if not command or not command.strip():
|
||||||
|
logger.debug(f"⚠️ [SSH.execute] Пустая команда!")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=False,
|
success=False,
|
||||||
error="Пустая команда"
|
error="Пустая команда"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Если сервер не указан - используем первый из конфигурации
|
# Если сервер не указан - используем первый из конфигурации
|
||||||
if server is None:
|
if server is None:
|
||||||
if not self.servers:
|
if not self.servers:
|
||||||
|
logger.debug(f"⚠️ [SSH.execute] Серверы не настроены!")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=False,
|
success=False,
|
||||||
error="Серверы не настроены. Проверьте SERVERS в .env"
|
error="Серверы не настроены. Проверьте SERVERS в .env"
|
||||||
)
|
)
|
||||||
server = list(self.servers.keys())[0]
|
server = list(self.servers.keys())[0]
|
||||||
logger.info(f"Сервер не указан, используем первый: {server}")
|
logger.info(f"Сервер не указан, используем первый: {server}")
|
||||||
|
logger.debug(f"🔍 [SSH.execute] Выбран сервер по умолчанию: {server}")
|
||||||
|
|
||||||
logger.info(f"SSH Executor: server={server}, command={command[:100]}")
|
logger.info(f"SSH Executor: server={server}, command={command[:100]}")
|
||||||
|
logger.debug(f"🔍 [SSH.execute] Вызов execute_command(server={server}, command={command[:50]}...)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.execute_command(command, server, timeout)
|
result = await self.execute_command(command, server, timeout)
|
||||||
|
logger.debug(f"🔍 [SSH.execute] Результат execute_command: returncode={result['returncode']}")
|
||||||
|
|
||||||
# Формируем красивый вывод
|
# Формируем красивый вывод
|
||||||
output = self._format_output(result)
|
output = self._format_output(result)
|
||||||
|
logger.debug(f"🔍 [SSH.execute] Вывод сформирован: {len(output)} chars")
|
||||||
|
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=result['returncode'] == 0,
|
success=result['returncode'] == 0,
|
||||||
|
|
@ -230,7 +318,7 @@ class SSHExecutorTool(BaseTool):
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Ошибка SSH Executor: {e}")
|
logger.exception(f"❌ [SSH.execute] Ошибка SSH Executor: {e}")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
success=False,
|
success=False,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
|
|
@ -241,21 +329,16 @@ class SSHExecutorTool(BaseTool):
|
||||||
"""Форматировать вывод команды."""
|
"""Форматировать вывод команды."""
|
||||||
output = []
|
output = []
|
||||||
|
|
||||||
# Добавляем заголовок с сервером и командой
|
|
||||||
output.append(f"🖥️ **SSH: {result.get('server', 'unknown')}**")
|
|
||||||
output.append(f"**Команда:** `{result.get('command', '')}`\n")
|
|
||||||
|
|
||||||
if result['stdout']:
|
if result['stdout']:
|
||||||
output.append(f"**Вывод:**\n```\n{result['stdout']}\n```")
|
output.append(f"**Вывод:**\n```\n{result['stdout']}\n```")
|
||||||
elif result['returncode'] == 0:
|
|
||||||
output.append("**Вывод:**\n```\n(команда выполнена без вывода)\n```")
|
|
||||||
|
|
||||||
if result['stderr']:
|
if result['stderr']:
|
||||||
output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```")
|
output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```")
|
||||||
|
|
||||||
output.append(f"\n**Код возврата:** `{result['returncode']}`")
|
if result['returncode'] != 0:
|
||||||
|
output.append(f"**Код возврата:** {result['returncode']}")
|
||||||
|
|
||||||
return "\n".join(output)
|
return "\n".join(output) if output else "Команда выполнена без вывода"
|
||||||
|
|
||||||
def add_server(self, name: str, host: str, port: int, username: str,
|
def add_server(self, name: str, host: str, port: int, username: str,
|
||||||
password: Optional[str] = None, client_keys: Optional[List[str]] = None):
|
password: Optional[str] = None, client_keys: Optional[List[str]] = None):
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,20 @@ def detect_input_type(text: str) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2.0) -> Tuple[str, bool]:
|
async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2.0, wait_for_completion: bool = False) -> Tuple[str, bool]:
|
||||||
"""
|
"""
|
||||||
Чтение вывода из SSH-процесса с таймаутом.
|
Чтение вывода из SSH-процесса с таймаутом.
|
||||||
Возвращает (вывод, завершён_ли_процесс).
|
|
||||||
|
Args:
|
||||||
|
process: SSH процесс для чтения
|
||||||
|
timeout: Таймаут для чтения данных (сек)
|
||||||
|
wait_for_completion: Если True, дождаться завершения процесса через process.wait()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(вывод, завершён_ли_процесс)
|
||||||
"""
|
"""
|
||||||
output = ""
|
output = ""
|
||||||
|
error_output = ""
|
||||||
is_done = False
|
is_done = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -61,13 +69,12 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2
|
||||||
logger.debug(f"Прочитано stdout: {len(data)} байт, всего: {len(output)}")
|
logger.debug(f"Прочитано stdout: {len(data)} байт, всего: {len(output)}")
|
||||||
else:
|
else:
|
||||||
# EOF
|
# EOF
|
||||||
|
logger.debug("SSH stdout EOF")
|
||||||
is_done = True
|
is_done = True
|
||||||
break
|
break
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
# Данные закончились
|
# Данные закончились по таймауту
|
||||||
logger.debug(f"Timeout stdout, прочитано: {len(output)} байт")
|
logger.debug(f"Timeout stdout ({timeout} сек), прочитано: {len(output)} байт")
|
||||||
if process.returncode is not None:
|
|
||||||
is_done = True
|
|
||||||
break
|
break
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
logger.debug(f"Ошибка декодирования UTF-8: {e}")
|
logger.debug(f"Ошибка декодирования UTF-8: {e}")
|
||||||
|
|
@ -78,11 +85,10 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2
|
||||||
is_done = True
|
is_done = True
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Ошибка чтения SSH stdout: {e}")
|
logger.debug(f"Ошибка чтения SSH stdout: {type(e).__name__}: {e}")
|
||||||
is_done = True
|
is_done = True
|
||||||
|
|
||||||
# Читаем stderr если есть
|
# Читаем stderr если есть
|
||||||
error_output = ""
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|
@ -97,16 +103,108 @@ async def read_ssh_output(process: asyncssh.SSHClientProcess, timeout: float = 2
|
||||||
except (asyncio.TimeoutError, Exception):
|
except (asyncio.TimeoutError, Exception):
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Ошибка чтения SSH stderr: {e}")
|
logger.debug(f"Ошибка чтения SSH stderr: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Объединяем stdout и stderr
|
# Объединяем stdout и stderr
|
||||||
if error_output:
|
if error_output:
|
||||||
output = output + error_output if output else error_output
|
output = output + error_output if output else error_output
|
||||||
|
|
||||||
logger.debug(f"read_ssh_output: output={len(output)} байт, is_done={is_done}, returncode={process.returncode}")
|
logger.info(f"read_ssh_output: output={len(output)} байт, is_done={is_done}, returncode={process.returncode}")
|
||||||
return output, is_done
|
return output, is_done
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_and_read_ssh(process: asyncssh.SSHClientProcess, timeout: float = 30.0) -> Tuple[str, str, int]:
|
||||||
|
"""
|
||||||
|
Чтение вывода SSH-процесса с ожиданием полного завершения.
|
||||||
|
Аналог asyncio.subprocess.communicate() для asyncssh.
|
||||||
|
|
||||||
|
Эта функция решает проблему с returncode, который становится доступен
|
||||||
|
только после завершения процесса. Читает stdout и stderr параллельно
|
||||||
|
с выполнением команды.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
process: SSH процесс
|
||||||
|
timeout: Максимальное время ожидания выполнения (сек)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(stdout, stderr, returncode)
|
||||||
|
"""
|
||||||
|
stdout_data = ""
|
||||||
|
stderr_data = ""
|
||||||
|
|
||||||
|
async def read_stream(stream, is_stdout=True):
|
||||||
|
"""Читает поток до EOF."""
|
||||||
|
data = ""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await stream.read()
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
data += chunk.decode('utf-8', errors='replace')
|
||||||
|
else:
|
||||||
|
data += str(chunk)
|
||||||
|
stream_name = "stdout" if is_stdout else "stderr"
|
||||||
|
logger.debug(f"{stream_name}: прочитано {len(chunk)} байт")
|
||||||
|
except Exception as e:
|
||||||
|
stream_name = "stdout" if is_stdout else "stderr"
|
||||||
|
logger.debug(f"{stream_name} завершен: {type(e).__name__}: {e}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Читаем stdout и stderr параллельно с ожиданием завершения
|
||||||
|
logger.debug(f"wait_and_read_ssh: запуск чтения (timeout={timeout})")
|
||||||
|
|
||||||
|
# Создаём задачи для чтения stdout и stderr
|
||||||
|
stdout_task = asyncio.create_task(read_stream(process.stdout, is_stdout=True))
|
||||||
|
stderr_task = asyncio.create_task(read_stream(process.stderr, is_stdout=False))
|
||||||
|
|
||||||
|
# Ждём завершения процесса с таймаутом
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=timeout)
|
||||||
|
logger.debug(f"wait_and_read_ssh: процесс завершился, returncode={process.returncode}")
|
||||||
|
|
||||||
|
# Ждём завершения чтения с коротким таймаутом
|
||||||
|
try:
|
||||||
|
stdout_data = await asyncio.wait_for(stdout_task, timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("wait_and_read_ssh: таймаут чтения stdout")
|
||||||
|
stdout_task.cancel()
|
||||||
|
try:
|
||||||
|
await stdout_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
stderr_data = await asyncio.wait_for(stderr_task, timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("wait_and_read_ssh: таймаут чтения stderr")
|
||||||
|
stderr_task.cancel()
|
||||||
|
try:
|
||||||
|
await stderr_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"wait_and_read_ssh: таймаут выполнения команды ({timeout} сек)")
|
||||||
|
# Отменяем задачи чтения
|
||||||
|
stdout_task.cancel()
|
||||||
|
stderr_task.cancel()
|
||||||
|
try:
|
||||||
|
await stdout_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await stderr_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
returncode = process.returncode if process.returncode is not None else 0
|
||||||
|
|
||||||
|
logger.info(f"wait_and_read_ssh: stdout={len(stdout_data)} байт, stderr={len(stderr_data)} байт, returncode={returncode}")
|
||||||
|
return stdout_data, stderr_data, returncode
|
||||||
|
|
||||||
|
|
||||||
def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]:
|
def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]:
|
||||||
"""
|
"""
|
||||||
Чтение вывода из PTY с таймаутом.
|
Чтение вывода из PTY с таймаутом.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue