v0.7.2: Исправление зависания бота и обработки длинного вывода

Исправленные проблемы:
- Бот зависал после выполнения команд из кнопок меню
- 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-02-28 11:10:29 +08:00
parent 577bfce85e
commit d2f22ee149
7 changed files with 277 additions and 124 deletions

34
bot.py
View File

@ -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}"
# Экранируем специальные символы Markdown в ответе ИИ
escaped_output = escape_markdown(full_output)
# Экранируем context_info тоже т.к. он содержит % символ
context_info = f"📊 Контекст: {context_percent}\\%\n🤖 AI: {provider_name}"
# НЕ используем 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"

View File

@ -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":

View File

@ -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:

View File

@ -93,7 +93,8 @@ async def _execute_local_command(query, command: str, working_dir: str):
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"
)
while not is_done:
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.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"
@ -147,6 +155,12 @@ async def _execute_local_command(query, command: str, working_dir: str):
)
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,7 +323,8 @@ async def _show_result(query, command: str, stdout: bytes, stderr: bytes, return
result += f"\n*Код возврата:* `{returncode}`"
# Экранируем backticks
# НЕ используем escape_markdown — вывод внутри ``` не требует экранирования
# Экранируем только backticks если они есть вне блоков кода
result = escape_markdown(result)
# Отправляем с разбивкой на части если нужно

View File

@ -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):

View File

@ -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,30 +188,57 @@ 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
state = state_manager.get(user_id)
# Начинаем с указанного сообщения
for i in range(start_from, total):
part, has_code, code_opened, code_closed = parts[i]
for i, (part, has_code, code_opened, code_closed) in enumerate(parts):
# Определяем 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:
@ -174,75 +247,49 @@ async def send_long_message(update: Update, text: str, parse_mode: str = None, p
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)
await send_method(part)
messages_sent += 1
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 = ещё не решил
state.output_next_index = i + 1 # С какого сообщения продолжить
state.output_text = text # Сохраняем текст для продолжения
state.output_parse_mode = parse_mode
logger.info(f"send_long_message: ждём нажатия кнопки (user_id={user_id}, remaining={remaining})")
logger.info(f"send_long_message: пауза после {i+1}/{total}, ждём кнопки (user_id={user_id})")
return True # Возвращаем True — есть продолжение
# 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.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:

View File

@ -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}")