diff --git a/bot.py b/bot.py index 209936c..f366cfb 100644 --- a/bot.py +++ b/bot.py @@ -168,40 +168,6 @@ async def handle_ai_task(update: Update, text: str): logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку") return - # === ПРОВЕРКА: Авторизация Qwen === - from bot.utils.qwen_oauth import is_authorized, get_authorization_url - - if not await is_authorized(): - logger.info(f"Пользователь {user_id} не авторизован в Qwen, получаем OAuth URL") - oauth_url = await get_authorization_url() - - if oauth_url: - # Устанавливаем флаг ожидания - state.waiting_for_qwen_oauth = True - - await update.message.reply_text( - "🔐 **Требуется авторизация Qwen Code**\n\n" - "Для работы с Qwen Code необходимо авторизоваться.\n\n" - f"🔗 **[Открыть ссылку для авторизации]({oauth_url})**\n\n" - "Или скопируй:\n" - f"`{oauth_url}`\n\n" - "📋 **Инструкция:**\n" - "1. Нажми на ссылку выше или скопируй её в браузер\n" - "2. Войди через Google или GitHub\n" - "3. Разрешите доступ\n" - "4. Вернись в Telegram и отправь любое сообщение\n\n" - "_Бот автоматически продолжит работу после авторизации._", - parse_mode="Markdown", - disable_web_page_preview=True - ) - else: - await update.message.reply_text( - "❌ **Ошибка получения OAuth URL**\n\n" - "Не удалось получить ссылку для авторизации. Попробуйте позже или используйте /qwen_auth", - parse_mode="Markdown" - ) - return - # === ПРОВЕРКА: Нужна ли компактификация? === # Проверяем порог заполненности контекста if compactor.check_compaction_needed(): @@ -406,6 +372,7 @@ async def handle_ai_task(update: Update, text: str): AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, + AI_PRESET_OPENCODE, ) if ai_preset == AI_PRESET_OFF: @@ -413,21 +380,29 @@ async def handle_ai_task(update: Update, text: str): logger.warning(f"Попытка обработки AI-запроса при отключенном ИИ (пресет={ai_preset})") return - if ai_preset == AI_PRESET_QWEN: - current_provider = "qwen" + # Проверяем какой провайдер выбран в настройках + current_provider = state.current_ai_provider + + if current_provider == "qwen": provider_display = "Qwen Code" - elif ai_preset in [AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO]: - current_provider = "gigachat" - # Для GigaChat пресетов устанавливаем нужную модель + elif current_provider == "opencode": + provider_display = f"Opencode ({state.opencode_model})" + elif current_provider == "gigachat": + # Для GigaChat используем модель из состояния пользователя from bot.tools.gigachat_tool import GigaChatConfig - if ai_preset == AI_PRESET_GIGA_LITE: - # Принудительно Lite модель + gigachat_model = state.gigachat_model + + if gigachat_model == "lite": GigaChatConfig.model = GigaChatConfig.model_lite - elif ai_preset == AI_PRESET_GIGA_PRO: - # Принудительно Pro модель + elif gigachat_model == "pro": GigaChatConfig.model = GigaChatConfig.model_pro - # ai_preset == AI_PRESET_GIGA_AUTO использует авто-переключение в gigachat_tool.py - provider_display = f"GigaChat ({ai_preset})" + elif gigachat_model == "max": + GigaChatConfig.model = GigaChatConfig.model_max + else: + # По умолчанию Lite + GigaChatConfig.model = GigaChatConfig.model_lite + + provider_display = f"GigaChat ({gigachat_model})" else: # По умолчанию Qwen current_provider = "qwen" @@ -548,6 +523,60 @@ async def handle_ai_task(update: Update, text: str): full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" provider_name = "GigaChat" + elif current_provider == "opencode": + # Opencode - использует provider_manager + try: + await status_msg.edit_text( + "⏳ 🚀 **Opencode думает...**", + parse_mode="Markdown" + ) + except Exception as e: + logger.debug(f"Ошибка обновления статуса для Opencode: {e}") + + # Формируем контекст для Opencode + context_messages = [] + + if summary: + context_messages.append({ + "role": "system", + "content": f"=== SUMMARY ДИАЛОГА ===\n{summary}" + }) + + if memory_context: + context_messages.append({ + "role": "system", + "content": f"=== КОНТЕКСТ ПАМЯТИ ===\n{memory_context}" + }) + + if history_context: + for line in history_context.split("\n"): + if line.startswith("user:"): + context_messages.append({"role": "user", "content": line[5:].strip()}) + elif line.startswith("assistant:"): + context_messages.append({"role": "assistant", "content": line[10:].strip()}) + + # Устанавливаем модель для пользователя (используем синглтон) + from bot.providers.opencode_provider import opencode_provider as global_opencode_provider + if hasattr(state, 'opencode_model'): + global_opencode_provider.set_model(user_id, state.opencode_model) + + result = await provider_manager.execute_request( + provider_id=current_provider, + user_id=user_id, + prompt=text, + system_prompt=system_prompt, + context=context_messages, + on_chunk=None + ) + + if result.get("success"): + full_output = result.get("content", "") + model_name = result.get("metadata", {}).get("model", state.opencode_model if hasattr(state, 'opencode_model') else 'minimax') + provider_name = f"Opencode ({model_name})" + else: + full_output = f"❌ **Ошибка {provider_manager.get_provider_info(current_provider).name}:**\n{result.get('error', 'Неизвестная ошибка')}" + provider_name = "Opencode" + # Добавляем ответ ИИ в историю и память if full_output and full_output != "⚠️ Не удалось получить ответ ИИ": state.ai_chat_history.append(f"Assistant: {full_output[:500]}") diff --git a/bot/ai_provider_manager.py b/bot/ai_provider_manager.py index 7c6ada7..4927fef 100644 --- a/bot/ai_provider_manager.py +++ b/bot/ai_provider_manager.py @@ -23,6 +23,7 @@ class AIProvider(Enum): """Доступные AI-провайдеры.""" QWEN = "qwen" GIGACHAT = "gigachat" + OPENCODE = "opencode" @dataclass @@ -68,6 +69,11 @@ class AIProviderManager: self._providers[AIProvider.GIGACHAT.value] = GigaChatProvider() logger.info("GigaChat Provider инициализирован") + # Opencode Provider + from bot.providers.opencode_provider import OpencodeProvider + self._providers[AIProvider.OPENCODE.value] = OpencodeProvider() + logger.info("Opencode Provider инициализирован") + def set_tools_registry(self, tools_registry: Dict[str, Any]): """Установить реестр инструментов для всех провайдеров.""" self._tools_registry = tools_registry @@ -88,6 +94,13 @@ class AIProviderManager: else: self._provider_status[AIProvider.GIGACHAT.value] = False + # Проверяем Opencode + opencode_provider = self._providers.get(AIProvider.OPENCODE.value) + if opencode_provider: + self._provider_status[AIProvider.OPENCODE.value] = opencode_provider.is_available() + else: + self._provider_status[AIProvider.OPENCODE.value] = False + def get_available_providers(self) -> List[str]: """Получить список доступных провайдеров.""" return [ @@ -116,15 +129,29 @@ class AIProviderManager: description="Sber GigaChat API — российская AI-модель от Сбера", available=self.is_provider_available(AIProvider.GIGACHAT.value), is_active=is_active + ), + AIProvider.OPENCODE.value: ProviderInfo( + id=AIProvider.OPENCODE.value, + name="Opencode", + description="Opencode AI — бесплатные модели (minimax, big-pickle, gpt-5-nano)", + available=self.is_provider_available(AIProvider.OPENCODE.value), + is_active=is_active ) } - return providers.get(provider_id) + return providers.get(provider_id, ProviderInfo( + id=provider_id, + name=provider_id, + description="Unknown provider", + available=False, + is_active=is_active + )) def get_all_providers_info(self, active_provider_id: str) -> List[ProviderInfo]: """Получить информацию обо всех провайдерах.""" return [ self.get_provider_info(AIProvider.QWEN.value, AIProvider.QWEN.value == active_provider_id), - self.get_provider_info(AIProvider.GIGACHAT.value, AIProvider.GIGACHAT.value == active_provider_id) + self.get_provider_info(AIProvider.GIGACHAT.value, AIProvider.GIGACHAT.value == active_provider_id), + self.get_provider_info(AIProvider.OPENCODE.value, AIProvider.OPENCODE.value == active_provider_id) ] def switch_provider(self, user_id: int, provider_id: str, state_manager) -> tuple[bool, str]: diff --git a/bot/handlers/ai_presets.py b/bot/handlers/ai_presets.py index 5bb54c5..9473416 100644 --- a/bot/handlers/ai_presets.py +++ b/bot/handlers/ai_presets.py @@ -20,6 +20,8 @@ from bot.models.user_state import ( AI_PRESET_GIGA_AUTO, AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, + AI_PRESET_GIGA_MAX, + AI_PRESET_OPENCODE, ) from bot.config import state_manager @@ -52,6 +54,16 @@ PRESET_DESCRIPTIONS = { "description": "Максимальное качество. Для сложных творческих задач.", "icon": "👑" }, + AI_PRESET_GIGA_MAX: { + "name": "💎 GigaChat Max", + "description": "Топовая модель для самых сложных задач.", + "icon": "💎" + }, + AI_PRESET_OPENCODE: { + "name": "⚡ Opencode", + "description": "Бесплатные модели (minimax, big-pickle, gpt-5-nano).", + "icon": "🚀" + }, } @@ -67,7 +79,7 @@ async def ai_presets_command(update: Update, context): state = state_manager.get(user_id) current_preset = state.ai_preset - # Формируем меню + # Формируем меню - Opencode и GigaChat теперь открывают подменю выбора моделей keyboard = [ [ InlineKeyboardButton( @@ -87,14 +99,18 @@ async def ai_presets_command(update: Update, context): callback_data=f"ai_preset_{AI_PRESET_GIGA_AUTO}" ) ], + # Кнопка GigaChat с подменю - убираем Lite и Pro из основного меню [ InlineKeyboardButton( - f"{'✅' if current_preset == AI_PRESET_GIGA_LITE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite", - callback_data=f"ai_preset_{AI_PRESET_GIGA_LITE}" - ), + f"{'✅' if current_preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶", + callback_data="gigachat_submenu" + ) + ], + # Кнопка Opencode с подменю + [ InlineKeyboardButton( - f"{'✅' if current_preset == AI_PRESET_GIGA_PRO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro", - callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}" + f"{'✅' if current_preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶", + callback_data="opencode_submenu" ) ], ] @@ -110,8 +126,8 @@ async def ai_presets_command(update: Update, context): output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['icon']} **ИИ Отключен** — {PRESET_DESCRIPTIONS[AI_PRESET_OFF]['description']}\n" output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['icon']} **Qwen Code** — {PRESET_DESCRIPTIONS[AI_PRESET_QWEN]['description']}\n" output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['icon']} **GigaChat Авто** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_AUTO]['description']}\n" - output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat Lite** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n" - output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} **GigaChat Pro** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['description']}" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} **GigaChat** — {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['description']}\n" + output += f"• {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} **Opencode** — {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['description']}" await update.message.reply_text(output, parse_mode="Markdown", reply_markup=reply_markup) @@ -142,6 +158,8 @@ async def ai_preset_callback(update: Update, context): # Для совместимости с существующим кодом if preset == AI_PRESET_QWEN: state.current_ai_provider = "qwen" + elif preset == AI_PRESET_OPENCODE: + state.current_ai_provider = "opencode" else: # Любой GigaChat state.current_ai_provider = "gigachat" @@ -150,7 +168,7 @@ async def ai_preset_callback(update: Update, context): output = f"✅ **Переключено на:** {preset_name}\n\n" output += f"{PRESET_DESCRIPTIONS[preset]['description']}" - # Обновляем инлайн-меню + # Обновляем инлайн-меню - с подменю для Opencode и GigaChat keyboard = [ [ InlineKeyboardButton( @@ -172,12 +190,14 @@ async def ai_preset_callback(update: Update, context): ], [ InlineKeyboardButton( - f"{'✅' if preset == AI_PRESET_GIGA_LITE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat Lite", - callback_data=f"ai_preset_{AI_PRESET_GIGA_LITE}" - ), + f"{'✅' if preset in [AI_PRESET_GIGA_LITE, AI_PRESET_GIGA_PRO, AI_PRESET_GIGA_MAX] else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_LITE]['icon']} GigaChat ▶", + callback_data="gigachat_submenu" + ) + ], + [ InlineKeyboardButton( - f"{'✅' if preset == AI_PRESET_GIGA_PRO else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_GIGA_PRO]['icon']} GigaChat Pro", - callback_data=f"ai_preset_{AI_PRESET_GIGA_PRO}" + f"{'✅' if preset == AI_PRESET_OPENCODE else '⬜'} {PRESET_DESCRIPTIONS[AI_PRESET_OPENCODE]['icon']} Opencode ▶", + callback_data="opencode_submenu" ) ], ] @@ -214,6 +234,16 @@ async def ai_giga_pro_command(update: Update, context): await switch_preset(update, AI_PRESET_GIGA_PRO) +async def ai_giga_max_command(update: Update, context): + """Быстрое переключение на GigaChat Max.""" + await switch_preset(update, AI_PRESET_GIGA_MAX) + + +async def ai_opencode_command(update: Update, context): + """Быстрое переключение на Opencode.""" + await switch_preset(update, AI_PRESET_OPENCODE) + + async def switch_preset(update: Update, preset: str): """Переключить пресет и показать уведомление.""" user_id = update.effective_user.id @@ -229,6 +259,8 @@ async def switch_preset(update: Update, preset: str): state.ai_chat_mode = True if preset == AI_PRESET_QWEN: state.current_ai_provider = "qwen" + elif preset == AI_PRESET_OPENCODE: + state.current_ai_provider = "opencode" else: state.current_ai_provider = "gigachat" @@ -260,5 +292,7 @@ def register_ai_preset_handlers(dispatcher): dispatcher.add_handler(CommandHandler("ai_giga_auto", ai_giga_auto_command)) dispatcher.add_handler(CommandHandler("ai_giga_lite", ai_giga_lite_command)) dispatcher.add_handler(CommandHandler("ai_giga_pro", ai_giga_pro_command)) + dispatcher.add_handler(CommandHandler("ai_giga_max", ai_giga_max_command)) + dispatcher.add_handler(CommandHandler("ai_opencode", ai_opencode_command)) logger.info("Обработчики AI-пресетов зарегистрированы") diff --git a/bot/handlers/callbacks.py b/bot/handlers/callbacks.py index 00167d2..db53f2f 100644 --- a/bot/handlers/callbacks.py +++ b/bot/handlers/callbacks.py @@ -8,6 +8,15 @@ 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 bot.models.user_state import ( + AI_PRESET_OFF, + AI_PRESET_QWEN, + AI_PRESET_GIGA_AUTO, + AI_PRESET_GIGA_LITE, + AI_PRESET_GIGA_PRO, + AI_PRESET_GIGA_MAX, + AI_PRESET_OPENCODE, +) from memory_system import memory_manager, get_user_profile_summary logger = logging.getLogger(__name__) @@ -47,11 +56,74 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): # Вместо отправки сообщения редактируем callback await query.edit_message_text(text, parse_mode=parse_mode, reply_markup=reply_markup) return None - + fake_update = type('FakeUpdate', (), {'message': FakeMessage(), 'effective_user': query.from_user})() await ai_presets_command(fake_update, context) return + # Обработчики подменю AI-пресетов + elif callback == "opencode_submenu": + # Подменю выбора моделей Opencode из AI-пресетов + state = state_manager.get(user_id) + current_model = state.opencode_model + state.ai_preset = AI_PRESET_OPENCODE + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.", + "big_pickle": "Большая бесплатная модель. Лучше для сложных задач.", + "gpt5": "Самая мощная бесплатная модель. Требует больше времени." + } + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n" + f"Выберите модель для использования:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "gigachat_submenu": + # Подменю выбора моделей GigaChat из AI-пресетов + state = state_manager.get(user_id) + current_model = state.gigachat_model + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="ai_preset_giga_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="ai_preset_giga_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="ai_preset_giga_max")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "lite": "Быстрая и экономичная модель для простых задач", + "pro": "Баланс скорости и качества для большинства задач", + "max": "Самая мощная модель для сложных задач" + } + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{current_model.upper()}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• 📱 **Lite** — {model_descriptions['lite']}\n" + f"• 🚀 **Pro** — {model_descriptions['pro']}\n" + f"• 💎 **Max** — {model_descriptions['max']}\n\n" + f"Выберите модель для использования:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + elif callback.startswith("continue_output_"): # Пользователь нажал "Продолжить" parts = callback.replace("continue_output_", "").split("_") @@ -165,6 +237,37 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): reply_markup=menu_builder.get_keyboard("network") ) + elif callback == "opencode_models_menu": + # Меню выбора моделей Opencode в предустановленных командах + state = state_manager.get(user_id) + current_model = state.opencode_model + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад", callback_data="preset_menu")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель", + "big_pickle": "Большая бесплатная модель", + "gpt5": "Самая мощная бесплатная модель" + } + + await query.edit_message_text( + f"🤖 **AI модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}\n\n" + f"Выберите модель:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + elif callback == "server_menu": # Сброс состояния редактирования/добавления сервера state.waiting_for_input = False @@ -577,21 +680,199 @@ async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): else: await query.edit_message_text( "ℹ️ **Компактификация не требуется**\n\n" - "_Недостаточно сообщений для сжатия или summary уже актуален._", + "История пуста или уже компактная.", parse_mode="Markdown", reply_markup=menu_builder.get_keyboard("memory") ) else: await query.edit_message_text( - f"⚠️ **Ошибка компактификации:**\n`{result.error}`", + f"❌ **Ошибка компактификации:**\n\n{result.error}", parse_mode="Markdown", reply_markup=menu_builder.get_keyboard("memory") ) except Exception as e: - logger.exception(f"Ошибка в memory_compact: {e}") await query.edit_message_text( - f"⚠️ **Ошибка компактификации:**\n`{e}`", + f"❌ **Ошибка:** {e}", parse_mode="Markdown", reply_markup=menu_builder.get_keyboard("memory") ) + elif callback == "opencode_model_menu": + state = state_manager.get(user_id) + current_model = state.opencode_model + state.current_ai_provider = "opencode" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if current_model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if current_model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + model_descriptions = { + "minimax": "Быстрая, бесплатная модель. Хорошо справляется с простыми задачами.", + "big_pickle": "Большая бесплатная модель. Лучше для сложных задач.", + "gpt5": "Самая мощная бесплатная модель. Требует больше времени." + } + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{current_model}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• ⚡ **minimax** — {model_descriptions['minimax']}\n" + f"• 🗃️ **big-pickle** — {model_descriptions['big_pickle']}\n" + f"• 🔬 **gpt-5-nano** — {model_descriptions['gpt5']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback.startswith("opencode_model_"): + model = callback.replace("opencode_model_", "") + state = state_manager.get(user_id) + state.opencode_model = model + + # Обновляем модель в OpencodeProvider + from bot.providers.opencode_provider import OpencodeProvider + provider = OpencodeProvider() + provider.set_model(user_id, model) + + model_names = { + "minimax": "⚡ minimax", + "big_pickle": "🗃️ big-pickle", + "gpt5": "🔬 gpt-5-nano" + } + + await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}") + + # Показываем меню снова + keyboard = [ + [InlineKeyboardButton(f"{'✅' if model == 'minimax' else '⬜'} ⚡ minimax", callback_data="opencode_model_minimax")], + [InlineKeyboardButton(f"{'✅' if model == 'big_pickle' else '⬜'} 🗃️ big-pickle", callback_data="opencode_model_big_pickle")], + [InlineKeyboardButton(f"{'✅' if model == 'gpt5' else '⬜'} 🔬 gpt-5-nano", callback_data="opencode_model_gpt5")], + [InlineKeyboardButton("⬅️ Назад к AI-пресетам", callback_data="ai_presets")], + ] + + await query.edit_message_text( + f"📡 **Выбор модели Opencode**\n\n" + f"Текущая модель: **{model_names.get(model, model)}**\n\n" + f"✅ *Модель изменена!*\n\n" + f"Выберите модель или вернитесь назад:", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + # --- Обработчики меню выбора AI-провайдера --- + elif callback == "ai_provider_selection_menu": + state = state_manager.get(user_id) + current_provider = state.current_ai_provider + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")], + [InlineKeyboardButton("⬅️ Назад", callback_data="settings")], + ] + + provider_descriptions = { + "qwen": "Бесплатный локальный AI от Alibaba", + "opencode": "Бесплатные модели (minimax, big-pickle, gpt-5-nano)", + "gigachat": "Российский AI от Сбера (Lite, Pro, Max)" + } + + await query.edit_message_text( + f"🤖 **Выбор AI-провайдера**\n\n" + f"Текущий провайдер: **{current_provider.upper()}**\n\n" + f"ℹ️ Описание провайдеров:\n" + f"• 🔄 **Qwen Code** — {provider_descriptions['qwen']}\n" + f"• 📡 **Opencode** — {provider_descriptions['opencode']}\n" + f"• 🧠 **GigaChat** — {provider_descriptions['gigachat']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "ai_provider_qwen": + state = state_manager.get(user_id) + state.current_ai_provider = "qwen" + + await query.answer("✅ Переключено на Qwen Code") + + current_provider = "qwen" + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_provider == 'qwen' else '⬜'} 🔄 Qwen Code", callback_data="ai_provider_qwen")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'opencode' else '⬜'} 📡 Opencode ▶", callback_data="opencode_model_menu")], + [InlineKeyboardButton(f"{'✅' if current_provider == 'gigachat' else '⬜'} 🧠 GigaChat ▶", callback_data="gigachat_model_menu")], + [InlineKeyboardButton("⬅️ Назад", callback_data="settings")], + ] + + await query.edit_message_text( + f"🤖 **Выбор AI-провайдера**\n\n" + f"Текущий провайдер: **QWEN CODE**\n\n" + f"✅ *Провайдер изменён!*\n\n" + f"ℹ️ Описание провайдеров:\n" + f"• 🔄 **Qwen Code** — Бесплатный локальный AI от Alibaba\n" + f"• 📡 **Opencode** — Бесплатные модели (minimax, big-pickle, gpt-5-nano)\n" + f"• 🧠 **GigaChat** — Российский AI от Сбера (Lite, Pro, Max)", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback == "gigachat_model_menu": + state = state_manager.get(user_id) + current_model = state.gigachat_model + state.current_ai_provider = "gigachat" + + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")], + [InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")], + ] + + model_descriptions = { + "lite": "Быстрая и экономичная модель для простых задач", + "pro": "Баланс скорости и качества для большинства задач", + "max": "Самая мощная модель для сложных задач" + } + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{current_model.upper()}**\n\n" + f"ℹ️ Описание моделей:\n" + f"• 📱 **Lite** — {model_descriptions['lite']}\n" + f"• 🚀 **Pro** — {model_descriptions['pro']}\n" + f"• 💎 **Max** — {model_descriptions['max']}", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + elif callback.startswith("gigachat_model_"): + model = callback.replace("gigachat_model_", "") + state = state_manager.get(user_id) + state.gigachat_model = model + state.current_ai_provider = "gigachat" + + model_names = { + "lite": "📱 GigaChat Lite", + "pro": "🚀 GigaChat Pro", + "max": "💎 GigaChat Max" + } + + await query.answer(f"✅ Модель изменена на {model_names.get(model, model)}") + + current_model = model + keyboard = [ + [InlineKeyboardButton(f"{'✅' if current_model == 'lite' else '⬜'} 📱 GigaChat Lite", callback_data="gigachat_model_lite")], + [InlineKeyboardButton(f"{'✅' if current_model == 'pro' else '⬜'} 🚀 GigaChat Pro", callback_data="gigachat_model_pro")], + [InlineKeyboardButton(f"{'✅' if current_model == 'max' else '⬜'} 💎 GigaChat Max", callback_data="gigachat_model_max")], + [InlineKeyboardButton("⬅️ Назад", callback_data="ai_provider_selection_menu")], + ] + + await query.edit_message_text( + f"🧠 **Выбор модели GigaChat**\n\n" + f"Текущая модель: **{model_names.get(model, model)}**\n\n" + f"✅ *Модель изменена!*\n\n" + f"Теперь используется GigaChat с выбранной моделью.", + parse_mode="Markdown", + reply_markup=InlineKeyboardMarkup(keyboard) + ) + diff --git a/bot/keyboards/menus.py b/bot/keyboards/menus.py index a31eb29..a2344a1 100644 --- a/bot/keyboards/menus.py +++ b/bot/keyboards/menus.py @@ -122,10 +122,20 @@ def init_menus(menu_builder: MenuBuilder): MenuItem("🔍 Поиск", "search_menu", icon="🔍"), MenuItem("📊 Система", "system_menu", icon="📊"), MenuItem("🌐 Сеть", "network_menu", icon="🌐"), + MenuItem("🤖 AI модели Opencode", "opencode_models_menu", icon="🤖"), MenuItem("⬅️ Назад", "main", icon="⬅️"), ] menu_builder.add_menu("preset", preset_menu) + # Меню моделей Opencode + opencode_models_menu = [ + MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon="⚡"), + MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"), + MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"), + MenuItem("⬅️ Назад", "preset", icon="⬅️"), + ] + menu_builder.add_menu("opencode_models", opencode_models_menu) + # Файловая система fs_menu = [ MenuItem("ls -la", "cmd_ls_la", command="ls -la", icon="📄"), @@ -172,10 +182,38 @@ def init_menus(menu_builder: MenuBuilder): MenuItem("📄 Изменить описание", "set_description", icon="📄"), MenuItem("🎨 Изменить иконку", "set_icon", icon="🎨"), MenuItem("🧠 Память ИИ", "memory_menu", icon="🧠"), + MenuItem("🤖 Выбор AI-провайдера", "ai_provider_selection_menu", icon="🤖"), MenuItem("⬅️ Назад", "main", icon="⬅️"), ] menu_builder.add_menu("settings", settings_menu) + # Меню выбора AI-провайдера + ai_provider_selection_menu = [ + MenuItem("🔄 Qwen Code", "ai_provider_qwen", icon="🔄"), + MenuItem("📡 Opencode ▶", "opencode_model_menu", icon="📡"), + MenuItem("🧠 GigaChat ▶", "gigachat_model_menu", icon="🧠"), + MenuItem("⬅️ Назад", "settings", icon="⬅️"), + ] + menu_builder.add_menu("ai_provider_selection", ai_provider_selection_menu) + + # Модели Opencode + opencode_model_menu = [ + MenuItem("⚡ minimax (по умолчанию)", "opencode_model_minimax", icon="⚡"), + MenuItem("🗃️ big-pickle", "opencode_model_big_pickle", icon="🗃️"), + MenuItem("🔬 gpt-5-nano", "opencode_model_gpt5", icon="🔬"), + MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"), + ] + menu_builder.add_menu("opencode_model", opencode_model_menu) + + # Модели GigaChat + gigachat_model_menu = [ + MenuItem("📱 GigaChat Lite (по умолчанию)", "gigachat_model_lite", icon="📱"), + MenuItem("🚀 GigaChat Pro", "gigachat_model_pro", icon="🚀"), + MenuItem("💎 GigaChat Max", "gigachat_model_max", icon="💎"), + MenuItem("⬅️ Назад", "ai_provider_selection", icon="⬅️"), + ] + menu_builder.add_menu("gigachat_model", gigachat_model_menu) + # Меню AI-провайдера ai_provider_menu = [ MenuItem("🔄 Переключить AI-провайдер", "ai_provider_toggle", icon="🔄"), diff --git a/bot/models/user_state.py b/bot/models/user_state.py index c944585..ddb3be0 100644 --- a/bot/models/user_state.py +++ b/bot/models/user_state.py @@ -11,6 +11,18 @@ AI_PRESET_QWEN = "qwen" # Qwen Code (бесплатно, локально) AI_PRESET_GIGA_AUTO = "giga_auto" # GigaChat авто-переключение (Lite/Pro) AI_PRESET_GIGA_LITE = "giga_lite" # GigaChat Lite (дешевле) AI_PRESET_GIGA_PRO = "giga_pro" # GigaChat Pro (максимальное качество) +AI_PRESET_GIGA_MAX = "giga_max" # GigaChat Max (топовая модель) +AI_PRESET_OPENCODE = "opencode" # Opencode (бесплатно, локально) + +# Модели Opencode +OPENCODE_MODEL_MINIMAX = "minimax" +OPENCODE_MODEL_BIG_PICKLE = "big_pickle" +OPENCODE_MODEL_GPT5 = "gpt5" + +# Модели GigaChat +GIGACHAT_MODEL_LITE = "lite" +GIGACHAT_MODEL_PRO = "pro" +GIGACHAT_MODEL_MAX = "max" @dataclass @@ -29,6 +41,8 @@ class UserState: messages_since_fact_extract: int = 0 # Счётчик для извлечения фактов ai_preset: str = AI_PRESET_QWEN # Текущий AI-пресет current_ai_provider: str = "qwen" # Текущий AI-провайдер (для совместимости) + opencode_model: str = OPENCODE_MODEL_MINIMAX # Модель Opencode + gigachat_model: str = GIGACHAT_MODEL_LITE # Модель GigaChat # Для управления длинным выводом waiting_for_output_control: bool = False # Ожидание решения пользователя diff --git a/bot/providers/opencode_provider.py b/bot/providers/opencode_provider.py new file mode 100644 index 0000000..72cc9f9 --- /dev/null +++ b/bot/providers/opencode_provider.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Opencode AI Provider - интеграция с opencode CLI. + +Использует opencode run для выполнения задач с бесплатными моделями: +- opencode/minimax-m2.5-free +- opencode/big-pickle +- opencode/gpt-5-nano + +Поддерживает RAG через память бота. +""" + +import os +import re +import asyncio +import logging +import subprocess +from pathlib import Path +from typing import Optional, Dict, Any, Callable, List +from dataclasses import dataclass, field + +from bot.base_ai_provider import ( + BaseAIProvider, + ProviderResponse, + AIMessage, + ToolCall, + ToolCallStatus, +) + +logger = logging.getLogger(__name__) + +OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/mirivlad/.opencode/bin/opencode") + +AVAILABLE_MODELS = { + "minimax": "opencode/minimax-m2.5-free", + "big_pickle": "opencode/big-pickle", + "gpt5": "opencode/gpt-5-nano", +} + +DEFAULT_MODEL = "minimax" + + +@dataclass +class OpencodeSession: + """Сессия пользователя с opencode.""" + user_id: int + model: str = DEFAULT_MODEL + history: List[Dict[str, str]] = field(default_factory=list) + + +class OpencodeProvider(BaseAIProvider): + """ + Opencode AI Provider. + + Использует opencode CLI для генерации ответов. + Поддерживает несколько бесплатных моделей. + """ + + def __init__(self): + self._sessions: Dict[int, OpencodeSession] = {} + self._default_model = DEFAULT_MODEL + self._initialized = False + + @property + def provider_name(self) -> str: + return "Opencode" + + @property + def supports_tools(self) -> bool: + return True + + @property + def supports_streaming(self) -> bool: + return False + + def is_available(self) -> bool: + """Проверка доступности opencode CLI.""" + return Path(OPENCODE_BIN).exists() + + def get_session(self, user_id: int) -> OpencodeSession: + """Получить или создать сессию пользователя.""" + if user_id not in self._sessions: + self._sessions[user_id] = OpencodeSession( + user_id=user_id, + model=self._default_model + ) + return self._sessions[user_id] + + def set_model(self, user_id: int, model_key: str): + """Установить модель для пользователя.""" + session = self.get_session(user_id) + if model_key in AVAILABLE_MODELS: + session.model = AVAILABLE_MODELS[model_key] + logger.info(f"User {user_id} switched to model: {session.model}") + + def get_model(self, user_id: int) -> str: + """Получить текущую модель пользователя (полное имя).""" + session = self.get_session(user_id) + # Возвращаем полное имя модели из AVAILABLE_MODELS + return AVAILABLE_MODELS.get(session.model, session.model) + + def get_available_models(self) -> Dict[str, str]: + """Получить список доступных моделей.""" + return AVAILABLE_MODELS.copy() + + def _build_context( + self, + system_prompt: Optional[str], + context: Optional[List[Dict[str, str]]], + memory_context: str = "" + ) -> str: + """Собрать полный контекст для opencode.""" + parts = [] + + if system_prompt: + parts.append(f"=== SYSTEM PROMPT ===\n{system_prompt}") + + if memory_context: + parts.append(f"=== MEMORY CONTEXT ===\n{memory_context}") + + if context: + history_text = "\n".join([ + f"{msg.get('role', 'user')}: {msg.get('content', '')}" + for msg in context + if msg.get('role') != 'system' + ]) + if history_text: + parts.append(f"=== CONVERSATION HISTORY ===\n{history_text}") + + return "\n\n".join(parts) + + async def _run_opencode( + self, + prompt: str, + model: str, + on_chunk: Optional[Callable[[str], Any]] = None + ) -> str: + """ + Выполнить запрос через opencode CLI. + + Args: + prompt: Запрос пользователя + model: Модель для использования + on_chunk: Callback для потокового вывода (не используется) + + Returns: + Ответ от opencode + """ + try: + logger.info(f"Opencode _run_opencode: model={model}, prompt_len={len(prompt) if prompt else 0}") + + # Используем stdin для передачи промпта + cmd = [ + OPENCODE_BIN, + "run", + "-m", model + ] + + logger.info(f"Running opencode cmd: {cmd}") + + # Кодируем промпт для stdin + prompt_bytes = prompt.encode('utf-8') if prompt else b'' + + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(Path.home()), + ) + + # Отправляем промпт в stdin + stdout, _ = await asyncio.wait_for( + process.communicate(input=prompt_bytes), + timeout=120.0 + ) + + full_output = stdout.decode('utf-8', errors='replace') + + # Очищаем от ANSI кодов и служебных символов + full_output = self._clean_output(full_output) + + return full_output + + except asyncio.TimeoutError: + logger.error("Opencode timeout") + return "⏱️ Таймаут выполнения (2 минуты)" + except Exception as e: + logger.error(f"Opencode error: {e}") + return f"❌ Ошибка opencode: {str(e)}" + + def _clean_output(self, output: str) -> str: + """Очистить вывод от служебных символов.""" + # Убираем ANSI escape последовательности + ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') + output = ansi_escape.sub('', output) + + # Убираем служебные строки + lines = output.split('\n') + cleaned_lines = [] + + for line in lines: + # Пропускаем служебные строки + if any(x in line.lower() for x in ['build', 'minimax', 'gpt', 'elapsed', 'rss', 'bun v']): + continue + if line.startswith('>'): + continue + if not line.strip(): + continue + cleaned_lines.append(line) + + return "\n".join(cleaned_lines).strip() + + async def chat( + self, + prompt: str, + system_prompt: Optional[str] = None, + context: Optional[List[Dict[str, str]]] = None, + tools: Optional[List[Dict[str, Any]]] = None, + on_chunk: Optional[Callable[[str], Any]] = None, + user_id: Optional[int] = None, + memory_context: Optional[str] = None, + **kwargs + ) -> ProviderResponse: + """ + Отправить запрос к Opencode. + + Args: + prompt: Запрос пользователя + system_prompt: Системный промпт + context: История диалога + tools: Доступные инструменты (схема) - пока не используется + on_chunk: Callback для потокового вывода + user_id: ID пользователя + memory_context: Контекст из памяти бота + + Returns: + ProviderResponse с ответом + """ + if not self.is_available(): + return ProviderResponse( + success=False, + error="Opencode CLI не найден", + provider_name=self.provider_name + ) + + if user_id is None: + return ProviderResponse( + success=False, + error="user_id обязателен для Opencode", + provider_name=self.provider_name + ) + + try: + # Получаем текущую модель + model = self.get_model(user_id) + logger.info(f"Opencode: user_id={user_id}, model={model}, session={self._sessions.get(user_id)}") + + # Собираем контекст + full_context = self._build_context( + system_prompt=system_prompt, + context=context, + memory_context=memory_context or "" + ) + + # Формируем полный промпт + # Когда prompt=None (из process_with_tools), используем контекст напрямую + if prompt is None: + full_prompt = full_context if full_context else "" + elif full_context: + full_prompt = f"{full_context}\n\n=== CURRENT REQUEST ===\n{prompt}" + else: + full_prompt = prompt + + # Добавляем информацию об инструментах если есть + if tools: + tools_info = self._format_tools_for_prompt(tools) + full_prompt = f"{full_prompt}\n\n=== AVAILABLE TOOLS ===\n{tools_info}" + + logger.info(f"Opencode request (model={model}): {str(prompt)[:50] if prompt else 'from context'}...") + + # Выполняем запрос + result = await self._run_opencode( + prompt=full_prompt, + model=model, + on_chunk=on_chunk + ) + + if not result: + result = "⚠️ Пустой ответ от Opencode" + + return ProviderResponse( + success=True, + message=AIMessage( + content=result, + metadata={"model": model} + ), + provider_name=self.provider_name + ) + + except Exception as e: + logger.error(f"Opencode provider error: {e}") + return ProviderResponse( + success=False, + error=str(e), + provider_name=self.provider_name + ) + + def _format_tools_for_prompt(self, tools: List[Dict[str, Any]]) -> str: + """Форматировать инструменты для промпта.""" + if not tools: + return "" + + lines = ["У тебя есть следующие инструменты:\n"] + + for tool in tools: + name = tool.get('name', 'unknown') + desc = tool.get('description', 'Нет описания') + params = tool.get('parameters', {}) + + lines.append(f"- {name}: {desc}") + if params: + props = params.get('properties', {}) + if props: + lines.append(f" Параметры: {', '.join(props.keys())}") + + return "\n".join(lines) + + async def execute_tool( + self, + tool_name: str, + tool_args: Dict[str, Any], + tool_call_id: Optional[str] = None, + **kwargs + ) -> ToolCall: + """Выполнить инструмент (заглушка).""" + return ToolCall( + tool_name=tool_name, + tool_args=tool_args, + tool_call_id=tool_call_id, + status=ToolCallStatus.PENDING + ) + + +# Глобальный экземпляр +opencode_provider = OpencodeProvider() diff --git a/bot/tools/gigachat_tool.py b/bot/tools/gigachat_tool.py index dc015a6..9cec3e2 100644 --- a/bot/tools/gigachat_tool.py +++ b/bot/tools/gigachat_tool.py @@ -37,6 +37,7 @@ class GigaChatConfig: model: str = "GigaChat-Pro" # Модель по умолчанию model_lite: str = "GigaChat" # Lite модель для простых запросов model_pro: str = "GigaChat-Pro" # Pro модель для сложных запросов + model_max: str = "GigaChat-Max" # Max модель для самых сложных задач api_url: str = "https://gigachat.devices.sberbank.ru/api/v1" timeout: int = 60 # Пороги для переключения моделей @@ -82,6 +83,7 @@ class GigaChatTool: model=os.getenv("GIGACHAT_MODEL", "GigaChat-Pro"), model_lite=os.getenv("GIGACHAT_MODEL_LITE", "GigaChat"), model_pro=os.getenv("GIGACHAT_MODEL_PRO", "GigaChat-Pro"), + model_max=os.getenv("GIGACHAT_MODEL_MAX", "GigaChat-Max"), complexity_token_threshold=int(os.getenv("GIGACHAT_TOKEN_THRESHOLD", "50")), complexity_keyword_threshold=int(os.getenv("GIGACHAT_KEYWORD_THRESHOLD", "2")), ) diff --git a/bot/tools/ssh_tool.py b/bot/tools/ssh_tool.py index 7f06b11..a312297 100644 --- a/bot/tools/ssh_tool.py +++ b/bot/tools/ssh_tool.py @@ -61,7 +61,7 @@ class SSHExecutorTool(BaseTool): SERVERS=name|host|port|user|tag|password Пример: - SERVERS=tomas|192.168.1.54|22|mirivlad|web|moloko22 + SERVERS=tomas|192.168.1.51|22|mirivlad|web|moloko22 """ servers_str = os.getenv('SERVERS', '') diff --git a/bot/utils/qwen_oauth.py.bak b/bot/utils/qwen_oauth.py.bak new file mode 100644 index 0000000..2d7e09f --- /dev/null +++ b/bot/utils/qwen_oauth.py.bak @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +Qwen OAuth 2.0 Device Flow клиент. +Реализует авторизацию через Device Authorization Grant (RFC 8628). +""" + +import os +import json +import hashlib +import secrets +import time +import aiohttp +import logging +from pathlib import Path +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + +# Qwen OAuth константы +QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai' +QWEN_OAUTH_DEVICE_CODE_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code' +QWEN_OAUTH_TOKEN_ENDPOINT = f'{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token' +QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56' +QWEN_OAUTH_SCOPE = 'openid profile email model.completion' + +# Пути для хранения токенов (как в qwen-code CLI) +QWEN_CONFIG_DIR = Path.home() / '.qwen' +QWEN_CREDENTIALS_FILE = QWEN_CONFIG_DIR / 'oauth_creds.json' + + +@dataclass +class QwenCredentials: + """OAuth токены Qwen.""" + access_token: str = '' + refresh_token: str = '' + token_type: str = 'Bearer' + expiry_date: int = 0 # Unix timestamp в миллисекундах + resource_url: str = 'portal.qwen.ai' + + def is_expired(self, buffer_minutes: int = 5) -> bool: + """Проверка истечения токена с буфером.""" + if not self.expiry_date: + return True + expiry_ms = self.expiry_date - (buffer_minutes * 60 * 1000) + return int(datetime.now().timestamp() * 1000) >= expiry_ms + + +@dataclass +class DeviceAuthorizationResponse: + """Ответ устройства авторизации.""" + device_code: str = '' + user_code: str = '' + verification_uri: str = '' + verification_uri_complete: str = '' + expires_in: int = 0 + interval: int = 5 # Polling interval в секундах + + @property + def authorization_url(self) -> str: + """Полная ссылка для авторизации.""" + return self.verification_uri_complete + + @property + def is_expired(self) -> bool: + """Истёк ли срок действия device code.""" + return time.time() > (self.expires_in - 30) # 30 сек буфер + + +class QwenOAuthClient: + """Qwen OAuth 2.0 клиент.""" + + def __init__(self, credentials_path: Optional[Path] = None): + self.credentials_path = credentials_path or QWEN_CREDENTIALS_FILE + self._credentials: Optional[QwenCredentials] = None + self._load_credentials() + + def _load_credentials(self) -> None: + """Загрузить токены из файла.""" + if self.credentials_path.exists(): + try: + with open(self.credentials_path, 'r') as f: + data = json.load(f) + self._credentials = QwenCredentials(**data) + logger.info(f"Токены загружены из {self.credentials_path}") + except Exception as e: + logger.error(f"Ошибка загрузки токенов: {e}") + self._credentials = None + else: + logger.debug("Файл с токенами не найден") + self._credentials = None + + # Загружаем code_verifier из device_code.json если есть + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if device_code_file.exists(): + try: + with open(device_code_file, 'r') as f: + data = json.load(f) + code_verifier = data.get('code_verifier', '') + if code_verifier: + self._code_verifier = code_verifier + logger.info(f"Code verifier загружен из {device_code_file}") + except Exception as e: + logger.debug(f"Ошибка загрузки code_verifier: {e}") + + def _save_credentials(self) -> None: + """Сохранить токены в файл.""" + if self._credentials: + self.credentials_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.credentials_path, 'w') as f: + json.dump(self._credentials.__dict__, f, indent=2) + # Устанавливаем права 600 (только владелец) + os.chmod(self.credentials_path, 0o600) + logger.info(f"Токены сохранены в {self.credentials_path}") + + def has_valid_token(self) -> bool: + """Проверка наличия валидного токена.""" + return self._credentials is not None and not self._credentials.is_expired() + + async def get_access_token(self) -> Optional[str]: + """Получить access token (обновляет если истёк).""" + if self.has_valid_token(): + return self._credentials.access_token + + if self._credentials and self._credentials.refresh_token: + # Пробуем обновить токен + if await self._refresh_token(): + return self._credentials.access_token + + return None + + async def request_device_authorization(self) -> DeviceAuthorizationResponse: + """ + Запросить Device Authorization. + + Returns: + DeviceAuthorizationResponse с данными для авторизации + """ + # Проверяем есть ли сохранённый code_verifier + if not hasattr(self, '_code_verifier') or not self._code_verifier: + # Генерируем PKCE code verifier и challenge + self._code_verifier = secrets.token_urlsafe(32) + + code_challenge = hashlib.sha256(self._code_verifier.encode()).hexdigest() + + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': QWEN_OAUTH_SCOPE, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': secrets.token_hex(16), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"Device authorization failed: {resp.status} - {text}") + + data = await resp.json() + + return DeviceAuthorizationResponse( + device_code=data.get('device_code', ''), + user_code=data.get('user_code', ''), + verification_uri=data.get('verification_uri', ''), + verification_uri_complete=data.get('verification_uri_complete', ''), + expires_in=data.get('expires_in', 900), + interval=data.get('interval', 5) + ) + + async def poll_for_token(self, device_code: str, timeout_seconds: int = 900) -> bool: + """ + Опрос сервера для получения токена после авторизации пользователем. + + Args: + device_code: Device code из request_device_authorization + timeout_seconds: Максимальное время ожидания + + Returns: + True если авторизация успешна + """ + if not hasattr(self, '_code_verifier'): + raise Exception("Code verifier not set. Call request_device_authorization first.") + + start_time = time.time() + interval = 5 # Начальный интервал + + while time.time() - start_time < timeout_seconds: + payload = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'code_verifier': self._code_verifier, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + logger.debug(f"Polling payload: grant_type={payload['grant_type']}, device_code={device_code[:20]}..., code_verifier={self._code_verifier[:20]}...") + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + data = await resp.json() + logger.info(f"Polling response: status={resp.status}, data={data}") + + if resp.status == 200: + # Успех! Сохраняем токены + self._credentials = QwenCredentials( + access_token=data.get('access_token', ''), + refresh_token=data.get('refresh_token', ''), + token_type=data.get('token_type', 'Bearer'), + expiry_date=int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000), + resource_url=data.get('resource_url', 'portal.qwen.ai') + ) + self._save_credentials() + logger.info("Авторизация успешна!") + return True + + error = data.get('error', '') + + if error == 'authorization_pending': + # Пользователь ещё не авторизовался + logger.debug("Ожидание авторизации пользователя...") + await asyncio.sleep(interval) + continue + + elif error == 'slow_down': + # Сервер просит увеличить интервал + interval += 5 + logger.debug(f"Увеличиваем интервал до {interval} сек") + await asyncio.sleep(interval) + continue + + elif error == 'expired_token': + logger.error("Device code истёк") + return False + + elif error == 'access_denied': + logger.error("Пользователь отклонил авторизацию") + return False + + else: + logger.error(f"Неизвестная ошибка: {error}") + return False + + logger.error("Таймаут авторизации") + return False + + async def _refresh_token(self) -> bool: + """Обновить access token используя refresh token.""" + if not self._credentials or not self._credentials.refresh_token: + return False + + payload = { + 'grant_type': 'refresh_token', + 'refresh_token': self._credentials.refresh_token, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status == 200: + data = await resp.json() + self._credentials.access_token = data.get('access_token', '') + self._credentials.refresh_token = data.get('refresh_token', self._credentials.refresh_token) + self._credentials.expiry_date = int(datetime.now().timestamp() * 1000) + (data.get('expires_in', 3600) * 1000) + self._save_credentials() + logger.info("Токен обновлён") + return True + else: + logger.error(f"Ошибка обновления токена: {resp.status}") + self._credentials = None # Очищаем неверные токены + return False + except Exception as e: + logger.error(f"Ошибка обновления токена: {e}") + return False + + def clear_credentials(self) -> None: + """Очистить сохранённые токены.""" + self._credentials = None + if self.credentials_path.exists(): + self.credentials_path.unlink() + logger.info("Токены очищены") + + +# Глобальный клиент (singleton) +_oauth_client: Optional[QwenOAuthClient] = None + + +def get_oauth_client() -> QwenOAuthClient: + """Получить OAuth клиент (singleton).""" + global _oauth_client + if _oauth_client is None: + _oauth_client = QwenOAuthClient() + return _oauth_client + + +async def get_authorization_url() -> Optional[str]: + """ + Получить URL для авторизации. + + Returns: + URL для авторизации или None если ошибка + """ + try: + # Проверяем есть ли активный device code + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if device_code_file.exists(): + with open(device_code_file, 'r') as f: + data = json.load(f) + start_time = data.get('start_time', 0) + expires_in = data.get('expires_in', 900) + code_verifier = data.get('code_verifier', '') + auth_url = data.get('authorization_url', '') + device_code = data.get('device_code', '') + + # Если device code ещё активен (900 сек = 15 мин) — используем его + # Проверяем что прошло меньше половины времени жизни для надёжности + if time.time() - start_time < expires_in / 2 and code_verifier and auth_url and device_code: + logger.info(f"Используем существующий device code (осталось {expires_in - (time.time() - start_time):.0f} сек)") + return auth_url + else: + logger.info("Device code истёк или скоро истечёт, запрашиваем новый") + # Удаляем старый файл + device_code_file.unlink() + + # Генерируем PKCE пару как в qwen-code CLI + import base64 + import hashlib + + code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') + code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode('utf-8').rstrip('=') + + # Запрос device authorization + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': QWEN_OAUTH_SCOPE, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': secrets.token_hex(16), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_DEVICE_CODE_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + if resp.status != 200: + text = await resp.text() + logger.error(f"Device authorization failed: {resp.status} - {text[:200]}") + raise Exception(f"Device authorization failed: {resp.status}") + + # Проверяем Content-Type + content_type = resp.headers.get('Content-Type', '') + if 'application/json' not in content_type: + logger.error(f"Unexpected content type: {content_type}") + raise Exception(f"Expected JSON response, got {content_type}") + + data = await resp.json() + + # Извлекаем данные из ответа + auth_url = data.get('verification_uri_complete', '') + device_code = data.get('device_code', '') + expires_in = data.get('expires_in', 900) + + if not auth_url or not device_code: + logger.error(f"Missing auth_url or device_code in response: {data}") + raise Exception("Invalid OAuth response") + + logger.info(f"Получен OAuth URL: {auth_url}") + + # Сохраняем device code и code verifier для polling + device_code_file.parent.mkdir(parents=True, exist_ok=True) + with open(device_code_file, 'w') as f: + json.dump({ + 'device_code': device_code, + 'code_verifier': code_verifier, # Сохраняем тот же code_verifier! + 'expires_in': expires_in, + 'start_time': time.time(), + 'authorization_url': auth_url + }, f, indent=2) + + # Устанавливаем права 600 (только владелец) + os.chmod(device_code_file, 0o600) + + logger.info(f"Device code сохранён: {device_code[:20]}..., expires in {expires_in}s") + return auth_url + + except aiohttp.ContentTypeError as e: + logger.error(f"Content-Type error: {e}") + return None + except Exception as e: + logger.error(f"Ошибка получения URL авторизации: {e}", exc_info=True) + return None + + +async def check_authorization_complete() -> bool: + """ + Проверить завершение авторизации (polling). + + Returns: + True если авторизация завершена успешно + """ + try: + # Читаем device code + device_code_file = QWEN_CONFIG_DIR / 'device_code.json' + if not device_code_file.exists(): + logger.debug("device_code.json не найден") + return False + + with open(device_code_file, 'r') as f: + data = json.load(f) + + device_code = data.get('device_code', '') + code_verifier = data.get('code_verifier', '') # Получаем code_verifier из файла + start_time = data.get('start_time', time.time()) + expires_in = data.get('expires_in', 900) + + logger.info(f"Device code: {device_code[:20]}..., code_verifier: {code_verifier[:20]}...") + + # Проверяем не истёк ли timeout + if time.time() - start_time > expires_in: + logger.warning("Device code истёк") + device_code_file.unlink() + return False + + # Polling с code_verifier из файла + logger.info("Запуск polling для получения токена...") + + payload = { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'device_code': device_code, + 'code_verifier': code_verifier, + 'client_id': QWEN_OAUTH_CLIENT_ID + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + form_data = '&'.join(f'{k}={v}' for k, v in payload.items()) + + async with aiohttp.ClientSession() as session: + async with session.post( + QWEN_OAUTH_TOKEN_ENDPOINT, + data=form_data, + headers=headers + ) as resp: + data = await resp.json() + logger.info(f"Polling response: status={resp.status}, data={data}") + + if resp.status == 200: + # Успех! Сохраняем токены + credentials = { + 'access_token': data.get('access_token', ''), + 'refresh_token': data.get('refresh_token', ''), + 'token_type': data.get('token_type', 'Bearer'), + 'expiry_date': int(time.time() * 1000) + (data.get('expires_in', 3600) * 1000), + 'resource_url': data.get('resource_url', 'portal.qwen.ai') + } + + # Сохраняем токены в файл + QWEN_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(QWEN_CREDENTIALS_FILE, 'w') as f: + json.dump(credentials, f, indent=2) + os.chmod(QWEN_CREDENTIALS_FILE, 0o600) + + device_code_file.unlink() + logger.info("Авторизация успешна! Токены сохранены.") + return True + + error = data.get('error', '') + + if error == 'authorization_pending': + logger.debug("Ожидание авторизации пользователя...") + return False + + elif error == 'slow_down': + logger.debug("Сервер просит увеличить интервал") + await asyncio.sleep(5) + return False + + elif error == 'expired_token': + logger.error("Device code истёк") + device_code_file.unlink() + return False + + elif error == 'access_denied': + logger.error("Пользователь отклонил авторизацию") + device_code_file.unlink() + return False + + elif error == 'invalid_request': + error_desc = data.get('error_description', '') + logger.error(f"Invalid request: {error_desc}") + # Проверяем не истёк ли code_verifier + if 'code_verifier' in error_desc.lower(): + logger.error("Code verifier не совпадает — удаляем device_code.json") + device_code_file.unlink() + return False + + else: + logger.error(f"Неизвестная ошибка: {error}") + return False + + except Exception as e: + logger.error(f"Ошибка проверки авторизации: {e}", exc_info=True) + return False + + +async def is_authorized() -> bool: + """Проверить авторизован ли пользователь.""" + client = get_oauth_client() + return client.has_valid_token() + + +async def get_access_token() -> Optional[str]: + """Получить access token.""" + client = get_oauth_client() + return await client.get_access_token() + + +def clear_authorization() -> None: + """Очистить авторизацию.""" + client = get_oauth_client() + client.clear_credentials() diff --git a/qwen_integration.py b/qwen_integration.py index 02acb58..58c2158 100644 --- a/qwen_integration.py +++ b/qwen_integration.py @@ -28,9 +28,7 @@ from bot.base_ai_provider import ( # Импортируем OAuth модуль и константы from bot.utils.qwen_oauth import ( - get_authorization_url, check_authorization_complete, - is_authorized, get_access_token, clear_authorization, QWEN_OAUTH_CLIENT_ID, @@ -364,15 +362,6 @@ class QwenCodeManager: session.output_buffer = "" try: - # ПРОВЕРКА: Проверяем авторизацию ПЕРЕД запуском qwen-code - # Это предотвращает ошибку "No auth type is selected" - if not is_authorized(): - logger.warning("Пользователь не авторизован в Qwen, получаем OAuth URL") - oauth_url = await get_authorization_url() - if oauth_url and session.on_oauth_url: - await session.on_oauth_url(oauth_url) - return "🔐 Требуется авторизация Qwen. Пожалуйста, пройдите по ссылке и вернитесь." - env = os.environ.copy() env["FORCE_COLOR"] = "0" diff --git a/system_prompt.md b/system_prompt.md index e4cd6df..5145b9d 100644 --- a/system_prompt.md +++ b/system_prompt.md @@ -80,7 +80,7 @@ rss_reader(action="list", limit=10, undigested_only=True) - Упоминания утилит: systemctl, journalctl, top, htop, df, du, free, ps, netstat **Доступные серверы:** -- `home` — 192.168.1.54 (пользователь: mirivlad) +- `home` — 192.168.1.51 (пользователь: mirivlad) **Параметры:** - `command` (str): Команда для выполнения @@ -234,7 +234,7 @@ file_system_tool(operation='copy', source='file.txt', destination='backup/file.t ### При SSH-командах: ``` -🖥️ **SSH: home (192.168.1.54)** +🖥️ **SSH: home (192.168.1.51)** **Команда:** `df -h` **Вывод:**