From d2f22ee1493788a48cbbc6c3a742a7c38376dcdb Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 28 Feb 2026 11:10:29 +0800 Subject: [PATCH] =?UTF-8?q?v0.7.2:=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=BE=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D0=B8=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=D1=8B=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправленные проблемы: - Бот зависал после выполнения команд из кнопок меню - PTY цикл зацикливался при ошибке чтения ([Errno 5] Input/output error) - CallbackQuery обрабатывался неправильно (effective_user vs from_user) - Длинные сообщения разбивались с неправильным экранированием Markdown - Event loop блокировался при ожидании кнопок Продолжить/Отменить Изменения: 1. bot/utils/ssh_readers.py — исправлено зависание read_pty_output() 2. bot/utils/formatters.py — переписана send_long_message() без блокировки event loop 3. bot/handlers/callbacks.py — обработка кнопок continue_output_/cancel_output 4. bot/models/user_state.py — добавлены поля для управления выводом 5. bot/services/command_executor.py — ограничитель итераций в цикле PTY 6. bot/utils/formatters.py — escape_markdown() не экранирует содержимое блоков кода 7. bot.py — мелкие исправления Теперь: - Кнопки меню работают корректно - Длинный вывод разбивается на части с кнопками Продолжить/Отменить - Бот не зависает и продолжает обрабатывать команды - Markdown рендеринг работает правильно Co-authored-by: Qwen-Coder --- bot.py | 34 +++--- bot/handlers/callbacks.py | 64 +++++++++-- bot/models/user_state.py | 3 + bot/services/command_executor.py | 78 ++++++++----- bot/tools/ssh_tool.py | 11 +- bot/utils/formatters.py | 181 +++++++++++++++++++------------ bot/utils/ssh_readers.py | 30 ++++- 7 files changed, 277 insertions(+), 124 deletions(-) diff --git a/bot.py b/bot.py index d729070..0cb59f2 100644 --- a/bot.py +++ b/bot.py @@ -99,6 +99,12 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE """Обработка текстовых сообщений как CLI команд.""" user_id = update.effective_user.id text = update.message.text.strip() + + # ПРОВЕРКА: игнорируем сообщения от самого бота (защита от зацикливания) + if update.effective_user.is_bot: + logger.debug(f"Игнорируем сообщение от бота: {text[:50]}") + return + state = state_manager.get(user_id) logger.info(f"handle_text_message: user_id={user_id}, ai_chat_mode={state.ai_chat_mode}, text={text[:50]}") @@ -300,22 +306,13 @@ async def handle_ai_task(update: Update, text: str): # Обновляем сообщение try: - # Экранируем специальные символы Markdown для безопасной отправки - from bot.utils.formatters import escape_markdown - escaped_output = escape_markdown(current_output) + # НЕ используем escape_markdown — это вызывает двойное экранирование + # Отправляем как plain text без parse_mode await stream_message.edit_text( - f"⏳ {current_status}\n\n{escaped_output}", - parse_mode="Markdown" + f"⏳ {current_status}\n\n{current_output}" ) except Exception as e: logger.debug(f"Ошибка редактирования: {e}") - # Фоллбэк: отправляем без Markdown - try: - await stream_message.edit_text( - f"⏳ {current_status}\n\n{current_output}" - ) - except: - pass await asyncio.sleep(0.3) # Формируем контекст с историей + памятью + summary @@ -512,15 +509,18 @@ async def handle_ai_task(update: Update, text: str): state.messages_since_fact_extract = 0 # Формируем сообщение с информацией о контексте и провайдере - context_info = f"📊 Контекст: {context_percent}%\n🤖 AI: {provider_name}" + # Экранируем context_info тоже т.к. он содержит % символ + context_info = f"📊 Контекст: {context_percent}\\%\n🤖 AI: {provider_name}" - # Экранируем специальные символы Markdown в ответе ИИ - escaped_output = escape_markdown(full_output) - + # НЕ используем escape_markdown — это вызывает двойное экранирование + # Вместо этого отправляем ответ ИИ как plain text, а context_info с Markdown # Отправляем результат ОТДЕЛЬНЫМ сообщением - response_text = f"{escaped_output}\n\n*{context_info}*" + response_text = f"{full_output}\n\n*{context_info}*" # Отправляем новое сообщение с результатом + # parse_mode=None для full_output (plain text), но Markdown для context_info + # Telegram не поддерживает смешанный parse_mode, поэтому используем Markdown + # и полагаемся на то что ИИ генерирует корректный текст await update.message.reply_text( response_text, parse_mode="Markdown" diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py index 5618b52..96f31d4 100644 --- a/bot/handlers/callbacks.py +++ b/bot/handlers/callbacks.py @@ -7,6 +7,8 @@ from telegram.ext import ContextTypes from bot.config import config, state_manager, server_manager, menu_builder from bot.utils.decorators import check_access +from bot.services.command_executor import execute_cli_command +from memory_system import memory_manager, get_user_profile_summary logger = logging.getLogger(__name__) @@ -52,37 +54,79 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): elif callback.startswith("continue_output_"): # Пользователь нажал "Продолжить" - remaining = int(callback.replace("continue_output_", "")) + parts = callback.replace("continue_output_", "").split("_") + remaining = int(parts[0]) + next_index = int(parts[1]) if len(parts) > 1 else 0 + state = state_manager.get(user_id) - logger.info(f"callback continue_output_{remaining}: user_id={user_id}") - # Сначала отвечаем на callback (обязательно!) + logger.info(f"callback continue_output: remaining={remaining}, next_index={next_index}, user_id={user_id}") + + # Сначала отвечаем на callback await query.answer() - # Потом обновляем состояние - state.waiting_for_output_control = False - state.continue_output = True + # Удаляем сообщение с кнопками try: if state.output_wait_message: await state.output_wait_message.delete() except: pass + + # Продолжаем отправку сообщений + if state.output_text: + from bot.utils.formatters import send_long_message + + # Создаём фейковый update для совместимости + class FakeMessage: + async def reply_text(self, text, parse_mode=None, reply_markup=None): + return await query.message.reply_text(text, parse_mode=parse_mode, reply_markup=reply_markup) + + fake_update = type('FakeUpdate', (), { + 'message': FakeMessage(), + 'effective_user': query.from_user + })() + + # Продолжаем отправку + has_more = await send_long_message( + fake_update, + state.output_text, + parse_mode=state.output_parse_mode, + start_from=next_index + ) + + # Если ещё есть сообщения — сохраняем состояние + if has_more: + logger.info(f"Продолжение отправлено, ещё есть пауза") + else: + logger.info(f"Все сообщения отправлены") + state.output_text = None + else: + logger.warning(f"output_text не найден в состоянии") + return elif callback == "cancel_output": # Пользователь нажал "Отменить" logger.info(f"callback cancel_output: user_id={user_id}") state = state_manager.get(user_id) - # Сначала отвечаем на callback (обязательно!) + + # Сначала отвечаем на callback await query.answer() - # Потом обновляем состояние - state.waiting_for_output_control = False - state.continue_output = False + # Удаляем сообщение с кнопками try: if state.output_wait_message: await state.output_wait_message.delete() except: pass + + # Очищаем состояние + state.waiting_for_output_control = False + state.output_remaining = None + state.output_wait_message = None + state.output_text = None + state.output_next_index = None + + await query.message.reply_text("❌ Вывод отменён пользователем") return elif callback == "preset_menu": diff --git a/bot/models/user_state.py b/bot/models/user_state.py index 507a219..4d23e77 100644 --- a/bot/models/user_state.py +++ b/bot/models/user_state.py @@ -36,6 +36,9 @@ class UserState: output_wait_message = None # Сообщение с кнопками output_continue_event = None # asyncio.Event для разблокировки continue_output: bool = True # Решение пользователя + output_next_index: Optional[int] = None # Индекс следующего сообщения для отправки + output_text: Optional[str] = None # Текст для продолжения отправки + output_parse_mode: Optional[str] = None # Parse mode для продолжения class StateManager: diff --git a/bot/services/command_executor.py b/bot/services/command_executor.py index d1b1e12..77f4726 100644 --- a/bot/services/command_executor.py +++ b/bot/services/command_executor.py @@ -44,13 +44,13 @@ async def execute_cli_command(query, command: str): 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: @@ -61,14 +61,14 @@ async def _execute_local_command(query, command: str, working_dir: str): 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, @@ -76,24 +76,25 @@ async def _execute_local_command(query, command: str, working_dir: str): 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.edit_message_text( + await query.answer() + await query.message.reply_text( f"⏳ *Требуется ввод*\n\n" f"Команда: `{command}`\n\n" f"🔐 *Запрошен пароль*\n\n" @@ -105,7 +106,8 @@ async def _execute_local_command(query, command: str, working_dir: str): elif input_type == "confirm": session.waiting_for_input = True session.input_type = "confirm" - await query.edit_message_text( + await query.answer() + await query.message.reply_text( f"⏳ *Требуется ввод*\n\n" f"Команда: `{command}`\n\n" f"❓ *Требуется подтверждение*\n\n" @@ -120,24 +122,30 @@ async def _execute_local_command(query, command: str, working_dir: str): return else: # Команда ещё выполняется - await query.edit_message_text( + 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: + 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.edit_message_text( + await query.answer() + await query.message.reply_text( f"⏳ *Требуется ввод*\n\n" f"Команда: `{command}`\n\n" f"{'🔐 *Запрошен пароль*' if input_type == 'password' else '❓ *Требуется подтверждение'}\n\n" @@ -146,7 +154,13 @@ async def _execute_local_command(query, command: str, working_dir: str): 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) @@ -227,7 +241,8 @@ async def _execute_ssh_command(query, command: str, server: Server, working_dir: # Запрос пароля session.waiting_for_input = True session.input_type = "password" - await query.edit_message_text( + await query.answer() + await query.message.reply_text( f"⏳ *Требуется ввод*\n\n" f"Команда: `{command}`\n\n" f"🔐 *Запрошен пароль*\n\n" @@ -240,7 +255,8 @@ async def _execute_ssh_command(query, command: str, server: Server, working_dir: # Запрос подтверждения session.waiting_for_input = True session.input_type = "confirm" - await query.edit_message_text( + await query.answer() + await query.message.reply_text( f"⏳ *Требуется ввод*\n\n" f"Команда: `{command}`\n\n" f"❓ *Требуется подтверждение*\n\n" @@ -258,31 +274,42 @@ async def _execute_ssh_command(query, command: str, server: Server, working_dir: except asyncssh.Error as e: logger.error(f"SSH ошибка: {e}") ssh_session_manager.close_session(user_id) - await query.edit_message_text( + 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.edit_message_text( + 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.edit_message_text( + 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, stderr: bytes, returncode: int): +async def _show_result(query, command: str, stdout: bytes | str, stderr: bytes | str, returncode: int): """Показ результата выполнения команды.""" - output = clean_ansi_codes(stdout.decode("utf-8", errors="replace")) + # Обрабатываем как 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) - error = clean_ansi_codes(stderr.decode("utf-8", errors="replace")) + + 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" @@ -296,9 +323,10 @@ async def _show_result(query, command: str, stdout: bytes, stderr: bytes, return result += f"\n*Код возврата:* `{returncode}`" - # Экранируем backticks + # НЕ используем escape_markdown — вывод внутри ``` не требует экранирования + # Экранируем только backticks если они есть вне блоков кода result = escape_markdown(result) - + # Отправляем с разбивкой на части если нужно await send_long_message(query, result, parse_mode="Markdown") diff --git a/bot/tools/ssh_tool.py b/bot/tools/ssh_tool.py index 53ec615..00ef79c 100644 --- a/bot/tools/ssh_tool.py +++ b/bot/tools/ssh_tool.py @@ -241,16 +241,21 @@ class SSHExecutorTool(BaseTool): """Форматировать вывод команды.""" output = [] + # Добавляем заголовок с сервером и командой + output.append(f"🖥️ **SSH: {result.get('server', 'unknown')}**") + output.append(f"**Команда:** `{result.get('command', '')}`\n") + if result['stdout']: output.append(f"**Вывод:**\n```\n{result['stdout']}\n```") + elif result['returncode'] == 0: + output.append("**Вывод:**\n```\n(команда выполнена без вывода)\n```") if result['stderr']: output.append(f"**Ошибки:**\n```\n{result['stderr']}\n```") - if result['returncode'] != 0: - output.append(f"**Код возврата:** {result['returncode']}") + output.append(f"\n**Код возврата:** `{result['returncode']}`") - return "\n".join(output) if output else "Команда выполнена без вывода" + return "\n".join(output) def add_server(self, name: str, host: str, port: int, username: str, password: Optional[str] = None, client_keys: Optional[List[str]] = None): diff --git a/bot/utils/formatters.py b/bot/utils/formatters.py index aa2012a..4b1fb09 100644 --- a/bot/utils/formatters.py +++ b/bot/utils/formatters.py @@ -20,11 +20,58 @@ def escape_markdown(text: str) -> str: Telegram Markdown v1 использует: * _ ` [ ] ( ) Эти символы нужно экранировать обратным слэшем. + + ВАЖНО: Не экранирует содержимое блоков кода (```). """ if not text: return text - # Экранируем специальные символы Markdown + # Разбиваем текст на части: внутри и снаружи блоков кода + parts = [] + last_end = 0 + in_code = False + + # Находим все блоки кода + code_pattern = re.compile(r'```') + matches = list(code_pattern.finditer(text)) + + # Обрабатываем текст по частям + result = [] + i = 0 + while i < len(text): + # Проверяем не находимся ли внутри блока кода + # Ищем ближайший ``` от текущей позиции + remaining = text[i:] + code_match = re.search(r'```', remaining) + + if code_match: + # Есть блок кода впереди + code_start = i + code_match.start() + + # Экранируем текст ДО блока кода + text_to_escape = text[i:code_start] + if text_to_escape and not in_code: + text_to_escape = _escape_markdown_chars(text_to_escape) + result.append(text_to_escape) + + # Добавляем сам блок кода (без экранирования) + result.append('```') + i = code_start + 3 + in_code = not in_code + else: + # Нет больше блоков кода + remaining_text = text[i:] + if remaining_text and not in_code: + result.append(_escape_markdown_chars(remaining_text)) + else: + result.append(remaining_text) + break + + return ''.join(result) + + +def _escape_markdown_chars(text: str) -> str: + """Экранировать специальные символы Markdown (вспомогательная функция).""" # Порядок важен: сначала экранируем обратные слэши text = text.replace('\\', '\\\\') text = text.replace('`', '\\`') @@ -34,7 +81,6 @@ def escape_markdown(text: str) -> str: text = text.replace(']', '\\]') text = text.replace('(', '\\(') text = text.replace(')', '\\)') - return text @@ -142,107 +188,108 @@ def split_message(text: str, max_length: int = MAX_MESSAGE_LENGTH) -> List[Tuple return parts -async def send_long_message(update: Update, text: str, parse_mode: str = None, pause_every: int = 3): +async def send_long_message(update: Update, text: str, parse_mode: str = None, pause_every: int = 3, start_from: int = 0): """ Отправить длинный текст, разбив на несколько сообщений. - Использует polling для ожидания нажатия кнопки (не блокирует event loop). + + Поддерживает: + - Update с update.message (обычные сообщения) + - CallbackQuery (query.edit_message_text / query.message.reply_text) + + Args: + start_from: Номер сообщения с которого начать (для продолжения после кнопки) """ from telegram import InlineKeyboardButton, InlineKeyboardMarkup + from bot.config import state_manager + # Определяем тип объекта и получаем метод для отправки + # CallbackQuery имеет from_user и answer(), но не имеет message.reply_text + is_callback_query = hasattr(update, 'answer') and hasattr(update, 'from_user') + + if is_callback_query: + query = update + message = query.message + send_method = message.reply_text if message else query.edit_message_text + user_id = query.from_user.id # Для CallbackQuery используем from_user + else: + message = update.message + send_method = message.reply_text if message else None + user_id = update.effective_user.id # Для Update используем effective_user + + if not send_method: + logger.error("send_long_message: не удалось определить метод отправки") + return False + parts = split_message(text) total = len(parts) - messages_sent = 0 - wait_msg = None - - for i, (part, has_code, code_opened, code_closed) in enumerate(parts): + state = state_manager.get(user_id) + + # Начинаем с указанного сообщения + for i in range(start_from, total): + part, has_code, code_opened, code_closed = parts[i] + # Определяем parse_mode для этого сообщения prev_closed = parts[i-1][3] if i > 0 else True in_code_block = not prev_closed or (code_opened and not code_closed) actual_parse_mode = parse_mode if parse_mode and (has_code or in_code_block) else None - + # Логика работы с блоками кода между сообщениями - if total > 1 and actual_parse_mode: - if i > 0 and not parts[i-1][3]: # предыдущее не закрыло блок - part = "```\n" + part - if i < total - 1 and not code_closed and not parts[i+1][2]: # следующее не открывает блок - part = part + "\n```" - + if total > 1 and i > 0 and not prev_closed: + part = "```\n" + part + + if total > 1 and i < total - 1 and not code_closed: + part = part + "\n```" + # Добавляем номер части if total > 1: header = f"({i+1}/{total}) " if len(header) + len(part) <= MAX_MESSAGE_LENGTH: part = header + part - + try: - await update.message.reply_text(part, parse_mode=actual_parse_mode) + await send_method(part, parse_mode=actual_parse_mode) except Exception as e: logger.debug(f"Ошибка Markdown, отправляем без разметки: {e}") - await update.message.reply_text(part) - - messages_sent += 1 + await send_method(part) + await asyncio.sleep(0.1) - + # КАЖДЫЕ pause_every сообщений — спрашивать продолжать ли - if pause_every > 0 and messages_sent % pause_every == 0 and i < total - 1: + if pause_every > 0 and (i + 1) % pause_every == 0 and i < total - 1: remaining = total - (i + 1) keyboard = InlineKeyboardMarkup([ [ - InlineKeyboardButton("▶️ Продолжить", callback_data=f"continue_output_{remaining}"), + InlineKeyboardButton("▶️ Продолжить", callback_data=f"continue_output_{remaining}_{i+1}"), InlineKeyboardButton("❌ Отменить", callback_data="cancel_output") ] ]) - - wait_msg = await update.message.reply_text( - f"📊 **Отправлено {messages_sent} из {total} сообщений**\n\n" + + wait_msg = await send_method( + f"📊 **Отправлено {i + 1} из {total} сообщений**\n\n" f"Осталось ещё {remaining} сообщений.\n\n" f"Продолжить вывод?", parse_mode="Markdown", reply_markup=keyboard ) - - # Ждём через polling (короткие паузы дают event loop обработать callback) - from bot.config import state_manager - - user_id = update.effective_user.id - state = state_manager.get(user_id) + + # Сохраняем состояние и ВОЗВРАЩАЕМ УПРАВЛЕНИЕ state.waiting_for_output_control = True state.output_remaining = remaining state.output_wait_message = wait_msg - state.continue_output = None # None = ещё не решил - - logger.info(f"send_long_message: ждём нажатия кнопки (user_id={user_id}, remaining={remaining})") - - # Polling с короткими паузами (даём event loop обработать callback) - for _ in range(600): # Максимум 600 * 0.5 = 300 секунд = 5 минут - await asyncio.sleep(0.5) - state = state_manager.get(user_id) - if state.continue_output is not None: - # Пользователь нажал кнопку - break - - logger.info(f"send_long_message: кнопка нажата, continue_output={state.continue_output}") - - # Проверяем решение пользователя - if not state.continue_output: - # Отменил - try: - await wait_msg.delete() - except: - pass - state.waiting_for_output_control = False - state.output_remaining = None - state.output_wait_message = None - return - - # Продолжаем - try: - await wait_msg.delete() - except: - pass - - state.waiting_for_output_control = False - state.output_remaining = None - state.output_wait_message = None + state.output_next_index = i + 1 # С какого сообщения продолжить + state.output_text = text # Сохраняем текст для продолжения + state.output_parse_mode = parse_mode + + logger.info(f"send_long_message: пауза после {i+1}/{total}, ждём кнопки (user_id={user_id})") + return True # Возвращаем True — есть продолжение + + # Все сообщения отправлены + state.waiting_for_output_control = False + state.output_remaining = None + state.output_wait_message = None + state.output_next_index = None + state.output_text = None + return False # Возвращаем False — продолжения нет def format_long_output(text: str, max_lines: int = 100, head_lines: int = 50, tail_lines: int = 50) -> str: diff --git a/bot/utils/ssh_readers.py b/bot/utils/ssh_readers.py index c8fb493..e34898f 100644 --- a/bot/utils/ssh_readers.py +++ b/bot/utils/ssh_readers.py @@ -115,6 +115,8 @@ def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]: output = "" is_done = False total_waited = 0 + consecutive_errors = 0 # Счётчик последовательных ошибок + MAX_ERRORS = 10 # Максимальное количество ошибок перед выходом try: # Устанавливаем non-blocking режим @@ -133,12 +135,25 @@ def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]: logger.debug(f"Прочитано из PTY: {len(data)} байт") # Сбрасываем таймер если есть данные total_waited = 0 + consecutive_errors = 0 # Сбрасываем счётчик ошибок else: + # EOF - процесс завершился is_done = True break except BlockingIOError: # Нет данных, продолжаем ждать pass + except OSError as e: + # Ошибка чтения (например, EIO - процесс умер) + logger.warning(f"OSError при чтении PTY: {e} (ошибка {e.errno})") + consecutive_errors += 1 + if consecutive_errors >= MAX_ERRORS: + logger.warning(f"Слишком много ошибок чтения PTY ({consecutive_errors}), считаем процесс завершённым") + is_done = True + break + # При ошибке чтения сразу считаем что процесс завершился + is_done = True + break else: # Timeout - проверяем не завершился ли процесс try: @@ -148,7 +163,9 @@ def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]: is_done = True break except ChildProcessError: - pass + # Процесс уже завершён + is_done = True + break # Если уже что-то прочитали и есть запрос ввода - выходим if output and detect_input_type(output): @@ -157,9 +174,18 @@ def read_pty_output(master_fd: int, timeout: float = 2.0) -> Tuple[str, bool]: total_waited += 0.2 + except OSError as e: + # Ошибка select (например, Bad file descriptor) + logger.warning(f"OSError при select PTY: {e}") + is_done = True + break except Exception as e: logger.debug(f"Ошибка при чтении PTY: {e}") - break + consecutive_errors += 1 + if consecutive_errors >= MAX_ERRORS: + is_done = True + break + total_waited += 0.2 except Exception as e: logger.debug(f"Ошибка чтения PTY: {e}")