From 769c662ab5c6b9ab3d28a61ac58ea920693abab3 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 1 Mar 2026 22:06:14 +0800 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=200.8.1=20?= =?UTF-8?q?-=20=D0=90=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20OAuth=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20Qwen=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: - Добавлена автоматическая OAuth авторизация для Qwen Code - При первом запросе к Qwen бот отправляет ссылку на авторизацию - После авторизации токены сохраняются в ~/.qwen/oauth_creds.json - Добавлена команда /qwen_auth для явной авторизации - Ссылка на авторизацию кликабельная в Telegram Новые файлы: - bot/utils/qwen_oauth.py — OAuth 2.0 Device Flow клиент - authorize_qwen.sh — скрипт для ручной авторизации Изменения: - bot.py — проверка авторизации в handle_ai_task, qwen_auth_command - bot/models/user_state.py — поле waiting_for_qwen_oauth - qwen_integration.py — интеграция с OAuth модулем - README.md — версия 0.8.1 Co-authored-by: Qwen-Coder --- authorize_qwen.sh | 20 ++ bot.py | 139 +++++++++- bot/models/user_state.py | 3 + bot/utils/qwen_oauth.py | 547 +++++++++++++++++++++++++++++++++++++++ qwen_integration.py | 113 +++++++- 5 files changed, 812 insertions(+), 10 deletions(-) create mode 100644 authorize_qwen.sh create mode 100644 bot/utils/qwen_oauth.py diff --git a/authorize_qwen.sh b/authorize_qwen.sh new file mode 100644 index 0000000..91bcb97 --- /dev/null +++ b/authorize_qwen.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Скрипт для авторизации Qwen Code на сервере + +echo "🔐 Авторизация Qwen Code..." +echo "" +echo "Запускаю qwen-code в интерактивном режиме..." +echo "Следуй инструкциям в браузере." +echo "" + +cd /home/mirivlad/telegram-bot +source venv/bin/activate + +# Запускаем qwen-code с простой командой чтобы инициировать авторизацию +qwen -p "привет, это тест авторизации" + +echo "" +echo "✅ Если авторизация прошла успешно, токен сохранён в ~/.qwen/oauth_creds.json" +echo "" +echo "Проверка файла с токеном:" +ls -la ~/.qwen/oauth_creds.json 2>/dev/null || echo "❌ Файл с токеном не найден" diff --git a/bot.py b/bot.py index 7a2cf7e..ddacfbe 100644 --- a/bot.py +++ b/bot.py @@ -138,6 +138,11 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE await handle_restart_password(update, text) return + # Проверка: не ждём ли завершения OAuth авторизации Qwen + if state.waiting_for_qwen_oauth: + await handle_qwen_oauth_completion(update, text) + return + # ПРОВЕРКА: режим чата с ИИ агентом if state.ai_chat_mode: logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}") @@ -157,12 +162,46 @@ async def handle_ai_task(update: Update, text: str): # === ПРОВЕРКА: AI-пресет === ai_preset = state.ai_preset - + # Если ИИ отключен — пропускаем обработку if ai_preset == "off": 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(): @@ -241,8 +280,22 @@ async def handle_ai_task(update: Update, text: str): """Callback для накопления полного вывода (не используется для streaming).""" pass - def on_oauth_url(url: str): - pass + async def on_oauth_url(url: str): + """Callback для OAuth URL — отправляем ссылку в чат.""" + try: + await update.message.reply_text( + "🔐 **Требуется авторизация Qwen Code**\n\n" + "Для работы с Qwen Code необходимо авторизоваться.\n\n" + "🔗 **Инструкция:**\n" + "1. Откройте ссылку: `{url}`\n" + "2. Войдите через Google/GitHub\n" + "3. После авторизации отправьте любое сообщение в чат\n\n" + "_Бот автоматически продолжит работу после авторизации._".format(url=url), + parse_mode="Markdown" + ) + logger.info(f"Отправлен OAuth URL пользователю {user_id}") + except Exception as e: + logger.error(f"Ошибка отправки OAuth URL: {e}") def on_event(event): """Обработка событий stream-json для обновления статуса.""" @@ -1712,6 +1765,7 @@ async def post_init(application: Application): BotCommand("cron", "Управление задачами"), BotCommand("stop", "Прервать SSH-сессию"), BotCommand("restart_bot", "Перезапустить бота"), + BotCommand("qwen_auth", "Авторизовать Qwen Code"), BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"), BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"), BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"), @@ -1861,6 +1915,84 @@ async def restart_bot_command(update: Update, context: ContextTypes.DEFAULT_TYPE ) +# ============================================ +# QWEN OAUTH АВТОРИЗАЦИЯ +# ============================================ + +@check_access +async def qwen_auth_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработка команды /qwen_auth - авторизация Qwen Code.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + from bot.utils.qwen_oauth import get_authorization_url, is_authorized + + # Проверяем есть ли уже валидный токен + if await is_authorized(): + await update.message.reply_text( + "✅ **Qwen уже авторизован!**\n\n" + "Токен действителен и готов к использованию.", + parse_mode="Markdown" + ) + return + + # Получаем OAuth URL + oauth_url = await get_authorization_url() + + if not oauth_url: + await update.message.reply_text( + "❌ **Ошибка получения OAuth URL**\n\n" + "Не удалось получить ссылку для авторизации. Попробуйте позже.", + parse_mode="Markdown" + ) + return + + # Устанавливаем флаг ожидания + state.waiting_for_qwen_oauth = True + + await update.message.reply_text( + "🔐 **Авторизация Qwen Code**\n\n" + "Для работы с Qwen Code необходимо авторизоваться.\n\n" + "🔗 **Откройте ссылку:**\n" + f"`{oauth_url}`\n\n" + "📋 **Инструкция:**\n" + "1. Нажмите на ссылку или скопируйте её в браузер\n" + "2. Войдите через Google или GitHub\n" + "3. Разрешите доступ\n" + "4. Вернитесь в Telegram и отправьте любое сообщение\n\n" + "_Бот автоматически проверит завершение авторизации._", + parse_mode="Markdown" + ) + + +async def handle_qwen_oauth_completion(update: Update, text: str): + """Проверка завершения OAuth авторизации.""" + user_id = update.effective_user.id + state = state_manager.get(user_id) + + from bot.utils.qwen_oauth import check_authorization_complete, is_authorized + + # Проверяем завершение авторизации + if await check_authorization_complete(): + state.waiting_for_qwen_oauth = False + + if await is_authorized(): + await update.message.reply_text( + "✅ **Авторизация успешна!**\n\n" + "Qwen Code готов к работе. Отправьте задачу.", + parse_mode="Markdown" + ) + return + + # Если авторизация ещё не завершена — показываем статус + await update.message.reply_text( + "⏳ **Проверка авторизации...**\n\n" + "Если вы уже авторизовались, бот продолжит работу.\n" + "Если нет — откройте ссылку для авторизации.", + parse_mode="Markdown" + ) + + async def handle_restart_password(update: Update, text: str): """Обработка пароля для перезапуска бота.""" user_id = update.effective_user.id @@ -2271,6 +2403,7 @@ def main(): application.add_handler(CommandHandler("menu", menu_command)) application.add_handler(CommandHandler("stop", stop_command)) application.add_handler(CommandHandler("restart_bot", restart_bot_command)) + application.add_handler(CommandHandler("qwen_auth", qwen_auth_command)) application.add_handler(CommandHandler("memory", memory_command)) application.add_handler(CommandHandler("compact", compact_command)) application.add_handler(CommandHandler("facts", facts_command)) diff --git a/bot/models/user_state.py b/bot/models/user_state.py index 53c614d..c944585 100644 --- a/bot/models/user_state.py +++ b/bot/models/user_state.py @@ -42,6 +42,9 @@ class UserState: # Для команды /restart_bot waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска + + # Для OAuth авторизации Qwen + waiting_for_qwen_oauth: bool = False # Ожидание завершения OAuth авторизации class StateManager: diff --git a/bot/utils/qwen_oauth.py b/bot/utils/qwen_oauth.py new file mode 100644 index 0000000..832f173 --- /dev/null +++ b/bot/utils/qwen_oauth.py @@ -0,0 +1,547 @@ +#!/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 ещё активен — используем его + if time.time() - start_time < expires_in - 60 and code_verifier and auth_url and device_code: + logger.info("Используем существующий device code") + return auth_url + + # Генерируем 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() + raise Exception(f"Device authorization failed: {resp.status} - {text}") + + data = await resp.json() + + auth_url = data.get('verification_uri_complete', '') + device_code = data.get('device_code', '') + expires_in = data.get('expires_in', 900) + + # Сохраняем 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) + + logger.info(f"Device code получен: {device_code[:20]}..., code_verifier сохранён") + return auth_url + + except Exception as e: + logger.error(f"Ошибка получения URL авторизации: {e}") + 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 ca37292..004e89d 100644 --- a/qwen_integration.py +++ b/qwen_integration.py @@ -18,6 +18,15 @@ from datetime import datetime, timedelta from typing import Optional, Dict, Callable, Any, List, Union from enum import Enum +# Импортируем OAuth модуль +from bot.utils.qwen_oauth import ( + get_authorization_url, + check_authorization_complete, + is_authorized, + get_access_token, + clear_authorization +) + logger = logging.getLogger(__name__) @@ -59,6 +68,7 @@ class QwenSession: state: QwenSessionState = QwenSessionState.STARTING process: Optional[subprocess.Popen] = None oauth_url: Optional[str] = None + on_oauth_url: Optional[Callable] = None # Callback для OAuth URL last_activity: datetime = field(default_factory=datetime.now) pending_task: Optional[str] = None output_buffer: str = "" @@ -113,7 +123,68 @@ class QwenCodeManager: self._sessions[user_id] = session logger.info(f"Создана сессия Qwen Code для пользователя {user_id}") return session - + + async def get_oauth_url(self) -> Optional[str]: + """ + Получить OAuth ссылку для авторизации Qwen Code. + + Returns: + OAuth URL или None если не удалось получить + """ + try: + import aiohttp + + # Генерируем code verifier и challenge (упрощённо) + import hashlib + import secrets + import uuid + + code_verifier = secrets.token_urlsafe(32) + code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest() + + # Запрос на получение device code (form-urlencoded как в оригинале) + payload = { + 'client_id': QWEN_OAUTH_CLIENT_ID, + 'scope': 'openid profile email model.completion', + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': str(uuid.uuid4()), + 'User-Agent': 'qwen-code-cli/0.11.0' + } + + # Формируем form-urlencoded тело + 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: + text = await resp.text() + if resp.status == 200: + try: + data = await resp.json() + verification_uri = data.get('verification_uri_complete', '') + logger.info(f"Получен OAuth URL: {verification_uri}") + return verification_uri + except Exception as json_err: + logger.error(f"Ошибка парсинга JSON: {json_err}") + logger.debug(f"Ответ сервера: {text[:200]}") + else: + logger.error(f"Ошибка получения OAuth: {resp.status}") + logger.debug(f"Ответ сервера: {text[:200]}") + return None + + except Exception as e: + logger.error(f"Ошибка получения OAuth URL: {e}") + return None + def close_session(self, user_id: int): """Закрыть сессию пользователя.""" session = self._sessions.pop(user_id, None) @@ -155,6 +226,7 @@ class QwenCodeManager: session.last_activity = datetime.now() session.pending_task = task + session.on_oauth_url = on_oauth_url # Сохраняем callback для OAuth # Добавляем системный промпт если нужно if use_system_prompt: @@ -180,13 +252,14 @@ class QwenCodeManager: # Запускаем qwen в интерактивном режиме с JSON выводом env = os.environ.copy() env["FORCE_COLOR"] = "0" # Отключаем цвета для парсинга - + cmd = [ self._qwen_command, "--output-format", "stream-json", "--input-format", "text", + "--auth-type", "qwen-oauth", # Явное указание типа авторизации ] - + logger.info(f"Запуск Qwen Code: {' '.join(cmd)}") session.process = subprocess.Popen( @@ -286,7 +359,8 @@ class QwenCodeManager: self._qwen_command, "-p", task, "--output-format", "stream-json", # Правильный streaming формат - "--yolo", # Авто-подтверждение + "--auth-type", "qwen-oauth", # Явное указание типа авторизации + # НЕ используем --yolo чтобы получить OAuth ссылку если нужно ] logger.info(f"Выполнение задачи (stream-json): {' '.join(cmd)}") @@ -333,6 +407,17 @@ class QwenCodeManager: session.output_buffer += line_str last_chunk_time = datetime.now() + # Проверяем на OAuth ссылку в текстовом выводе + oauth_match = re.search( + r'https://chat\.qwen\.ai/authorize\?user_code=([A-Za-z0-9_-]+)', + line_str + ) + if oauth_match: + oauth_url = oauth_match.group(0) + logger.info(f"Обнаружена OAuth ссылка: {oauth_url}") + if session and session.on_oauth_url: + await session.on_oauth_url(oauth_url) + # Парсим JSON событие и извлекаем текст await self._process_stream_lines( line_str, on_output, on_chunk, on_event, session @@ -437,9 +522,23 @@ class QwenCodeManager: # Проверяем на ошибку if event_data.get('is_error'): - error_text = event_data.get('error', 'Неизвестная ошибка') - logger.error(f"Ошибка Qwen: {error_text}") - + error_obj = event_data.get('error', {}) + error_message = error_obj.get('message', 'Неизвестная ошибка') if isinstance(error_obj, dict) else str(error_obj) + logger.error(f"Ошибка Qwen: {error_message}") + + # Проверяем ошибку авторизации — получаем OAuth URL + if 'No auth type' in error_message or 'auth type is not selected' in error_message: + # Получаем OAuth URL через API + oauth_url = await self.get_oauth_url() + if not oauth_url: + oauth_url = f"{QWEN_OAUTH_BASE_URL}/" + + logger.info(f"Требуется OAuth: {oauth_url}") + + # Вызываем on_oauth_url если есть в session + if session and hasattr(session, 'on_oauth_url') and session.on_oauth_url: + await session.on_oauth_url(oauth_url) + elif event_type == 'system': subtype = event_data.get('subtype', '') if subtype == 'session_start':