From 5d451ff870009bd5ea7599233ce01e622b593e80 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 24 Feb 2026 04:09:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20Qwen=20Code=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые возможности: - Команда /ai для выполнения задач через Qwen Code - Автоматический запуск сессии при первой задаче - Обработка OAuth авторизации (ссылка отправляется в чат) - Команды /ai status и /ai stop для управления сессией - Таймаут неактивности 30 минут - Буферизация вывода Файлы: - qwen_integration.py — менеджер сессий Qwen Code - bot.py — команда /ai и хендлеры Пример использования: /ai создай функцию Python для сортировки списка /ai status /ai stop Co-authored-by: Qwen-Coder --- bot.py | 89 ++++++++++++++ qwen_integration.py | 275 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 qwen_integration.py diff --git a/bot.py b/bot.py index 63e046f..1677b39 100644 --- a/bot.py +++ b/bot.py @@ -24,6 +24,7 @@ from datetime import datetime, timedelta import pexpect import asyncssh +from qwen_integration import qwen_manager, QwenSessionState from dotenv import load_dotenv from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand @@ -2609,6 +2610,7 @@ async def post_init(application: Application): BotCommand("help", "Справка"), BotCommand("settings", "Настройки"), BotCommand("stop", "Прервать SSH-сессию"), + BotCommand("ai", "Задача для Qwen Code AI"), ] await application.bot.set_my_commands(commands) @@ -2636,6 +2638,92 @@ async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) + + +# ============================================ +# КОМАНДЫ ДЛЯ РАБОТЫ С QWEN CODE (ИИ) +# ============================================ + +@check_access +async def ai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /ai - выполнение задачи через Qwen Code.""" + user_id = update.effective_user.id + task = " ".join(context.args).strip() + + if not task: + await update.message.reply_text( + "🤖 *Qwen Code AI*\n\n" + "Использование:\n" + "`/ai <задача>`\n\n" + "Примеры:\n" + "`/ai создай функцию Python для сортировки списка`\n" + "`/ai объясни код в файле main.py`\n" + "`/ai найди баги в этом коде`\n\n" + "Команды:\n" + "`/ai status` — статус сессии\n" + "`/ai stop` — завершить сессию", + parse_mode="Markdown" + ) + return + + # Специальные команды + if task == "status": + session = qwen_manager.get_session(user_id) + if session: + await update.message.reply_text( + f"🤖 *Статус сессии Qwen Code*\n\n" + f"Состояние: `{session.state.value}`\n" + f"Последняя активность: {session.last_activity.strftime('%H:%M:%S')}\n" + f"Задача: `{session.pending_task or 'Нет'}`", + parse_mode="Markdown" + ) + else: + await update.message.reply_text("ℹ️ Активных сессий нет") + return + + if task == "stop": + qwen_manager.close_session(user_id) + await update.message.reply_text("✅ Сессия Qwen Code завершена") + return + + # Отправляем задачу в ИИ + await update.message.reply_text("⏳ 🤖 Думаю...", parse_mode="Markdown") + + output_buffer = [] + oauth_url_sent = False + + def on_output(text: str): + output_buffer.append(text) + + def on_oauth_url(url: str): + nonlocal oauth_url_sent + if not oauth_url_sent: + oauth_url_sent = True + asyncio.create_task(update.message.reply_text( + f"🔐 *Требуется авторизация Qwen Code*\n\n" + f"Откройте ссылку для авторизации:\n" + f"{url}\n\n" + f"После авторизации отправьте команду снова.", + parse_mode="Markdown" + )) + + # Выполняем задачу + result = await qwen_manager.run_task(user_id, task, on_output, on_oauth_url) + + # Если это не OAuth — показываем результат + if not oauth_url_sent: + full_output = "".join(output_buffer) + + if len(full_output) > 4000: + full_output = full_output[:4000] + "\n... (вывод обрезан)" + + await update.message.reply_text( + f"🤖 *Результат:*\n\n" + f"```\n{full_output if full_output else result}\n```", + parse_mode="Markdown" + ) + + def main(): """Точка входа.""" # Чтение токена только из переменной окружения @@ -2667,6 +2755,7 @@ def main(): application.add_handler(CommandHandler("stop", stop_command)) application.add_handler(CallbackQueryHandler(menu_callback)) application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message)) + application.add_handler(CommandHandler("ai", ai_command)) # Запуск logger.info("Запуск бота...") diff --git a/qwen_integration.py b/qwen_integration.py new file mode 100644 index 0000000..aea1942 --- /dev/null +++ b/qwen_integration.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Интеграция с Qwen Code CLI. +Запуск, управление сессиями, обработка OAuth. +""" + +import os +import re +import asyncio +import subprocess +import logging +from pathlib import Path +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Dict, Callable, Any +from enum import Enum + +logger = logging.getLogger(__name__) + + +class QwenSessionState(Enum): + """Состояние сессии Qwen Code.""" + STARTING = "starting" + WAITING_FOR_OAUTH = "waiting_for_oauth" + READY = "ready" + BUSY = "busy" + ERROR = "error" + + +@dataclass +class QwenSession: + """Сессия Qwen Code.""" + user_id: int + state: QwenSessionState = QwenSessionState.STARTING + process: Optional[subprocess.Popen] = None + oauth_url: Optional[str] = None + last_activity: datetime = field(default_factory=datetime.now) + pending_task: Optional[str] = None + output_buffer: str = "" + + SESSION_TIMEOUT = timedelta(minutes=30) # Таймаут неактивности + + def is_expired(self) -> bool: + return datetime.now() - self.last_activity > self.SESSION_TIMEOUT + + +class QwenCodeManager: + """Менеджер сессий Qwen Code.""" + + def __init__(self, working_dir: str = None): + self._sessions: Dict[int, QwenSession] = {} + self._working_dir = working_dir or str(Path.home()) + self._qwen_command = "qwen" + + def get_session(self, user_id: int) -> Optional[QwenSession]: + """Получить сессию пользователя.""" + session = self._sessions.get(user_id) + if session and session.is_expired(): + self.close_session(user_id) + return None + return session + + def create_session(self, user_id: int) -> QwenSession: + """Создать новую сессию.""" + session = QwenSession(user_id=user_id) + self._sessions[user_id] = session + logger.info(f"Создана сессия Qwen Code для пользователя {user_id}") + return session + + def close_session(self, user_id: int): + """Закрыть сессию пользователя.""" + session = self._sessions.pop(user_id, None) + if session and session.process: + try: + session.process.terminate() + session.process.wait(timeout=5) + except Exception as e: + logger.warning(f"Ошибка при закрытии сессии Qwen: {e}") + logger.info(f"Закрыта сессия Qwen Code для пользователя {user_id}") + + def has_active_session(self, user_id: int) -> bool: + """Проверка наличия активной сессии.""" + session = self.get_session(user_id) + return session is not None and session.state != QwenSessionState.ERROR + + async def run_task(self, user_id: int, task: str, + on_output: Callable[[str], Any], + on_oauth_url: Callable[[str], Any]) -> str: + """ + Выполнить задачу в Qwen Code. + + Args: + user_id: ID пользователя + task: Задача для выполнения + on_output: Callback для вывода (вызывается при появлении вывода) + on_oauth_url: Callback для OAuth URL (вызывается если нужна авторизация) + + Returns: + Результат выполнения + """ + session = self.get_session(user_id) + + # Если сессии нет или она в ошибке — создаём новую + if not session or session.state == QwenSessionState.ERROR: + session = self.create_session(user_id) + + session.last_activity = datetime.now() + session.pending_task = task + + # Если сессия ещё не готова (ожидает OAuth или запуска) + if session.state in [QwenSessionState.STARTING, QwenSessionState.WAITING_FOR_OAUTH]: + return await self._start_session(session, on_output, on_oauth_url, task) + + # Если сессия готова — выполняем задачу + return await self._execute_task(session, task, on_output) + + async def _start_session(self, session: QwenSession, + on_output: Callable[[str], Any], + on_oauth_url: Callable[[str], Any], + pending_task: str = None) -> str: + """Запустить сессию Qwen Code.""" + session.state = QwenSessionState.STARTING + + try: + # Запускаем qwen в интерактивном режиме с JSON выводом + env = os.environ.copy() + env["FORCE_COLOR"] = "0" # Отключаем цвета для парсинга + + cmd = [ + self._qwen_command, + "--output-format", "stream-json", + "--input-format", "text", + ] + + logger.info(f"Запуск Qwen Code: {' '.join(cmd)}") + + session.process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=self._working_dir, + env=env, + text=True, + bufsize=1 + ) + + # Читаем вывод пока не поймём состояние + output = "" + oauth_detected = False + + while True: + line = session.process.stdout.readline() + if not line: + break + + output += line + on_output(line) + + # Проверяем на OAuth URL + oauth_match = re.search( + r'https://oauth\.qwen\.ai/[^>\s]+|' + r'https://[^>\s]*qwen[^>\s]*/oauth[^>\s]*|' + r'Authorize.*?https?://[^\s]+', + line, + re.IGNORECASE + ) + + if oauth_match: + oauth_url = oauth_match.group(0) + session.oauth_url = oauth_url + session.state = QwenSessionState.WAITING_FOR_OAUTH + on_oauth_url(oauth_url) + oauth_detected = True + logger.info(f"Обнаружен OAuth URL: {oauth_url}") + break + + # Проверяем на готовность + if "ready" in line.lower() or "assistant" in line.lower(): + session.state = QwenSessionState.READY + logger.info("Сессия Qwen Code готова") + break + + # Таймаут запуска + if session.process.poll() is not None: + session.state = QwenSessionState.ERROR + return f"❌ Ошибка запуска Qwen Code: {output}" + + # Если после запуска есть отложенная задача — выполняем + if pending_task and session.state == QwenSessionState.READY: + return await self._execute_task(session, pending_task, on_output) + + if oauth_detected: + return "⏳ Ожидание авторизации..." + + return "✅ Сессия запущена" + + except Exception as e: + session.state = QwenSessionState.ERROR + logger.error(f"Ошибка запуска сессии Qwen: {e}") + return f"❌ Ошибка: {str(e)}" + + async def _execute_task(self, session: QwenSession, + task: str, + on_output: Callable[[str], Any]) -> str: + """Выполнить задачу в активной сессии.""" + session.state = QwenSessionState.BUSY + session.output_buffer = "" + + try: + # Отправляем задачу + session.process.stdin.write(task + "\n") + session.process.stdin.flush() + + # Читаем ответ + output = "" + timeout = 300 # 5 минут на выполнение + + start_time = datetime.now() + + while True: + # Проверяем таймаут + if (datetime.now() - start_time).total_seconds() > timeout: + output += "\n\n⚠️ Таймаут выполнения (5 минут)" + break + + # Проверяем процесс + if session.process.poll() is not None: + # Процесс завершился + remaining = session.process.stdout.read() + if remaining: + output += remaining + on_output(remaining) + break + + # Читаем вывод + line = session.process.stdout.readline() + if line: + output += line + session.output_buffer += line + on_output(line) + + # Небольшая пауза чтобы не блокировать + await asyncio.sleep(0.1) + + session.state = QwenSessionState.READY + session.last_activity = datetime.now() + + return self._parse_output(output) + + except Exception as e: + session.state = QwenSessionState.ERROR + logger.error(f"Ошибка выполнения задачи: {e}") + return f"❌ Ошибка: {str(e)}" + + def _parse_output(self, output: str) -> str: + """ + Распарсить JSON вывод qwen-code. + Если вывод не JSON — вернуть как есть. + """ + # Пока просто возвращаем очищенный вывод + # В будущем можно парсить JSON stream-format + lines = output.split('\n') + cleaned = [] + + for line in lines: + # Убираем служебные сообщения + if line.strip() and not line.startswith('{'): + cleaned.append(line) + + return '\n'.join(cleaned) if cleaned else output + + +# Глобальный менеджер +qwen_manager = QwenCodeManager()