Версия 0.8.1 - Автоматическая OAuth авторизация Qwen Code

Основные изменения:
- Добавлена автоматическая 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 <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-01 22:06:14 +08:00
parent 9854836b17
commit 769c662ab5
5 changed files with 812 additions and 10 deletions

20
authorize_qwen.sh Normal file
View File

@ -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 "❌ Файл с токеном не найден"

139
bot.py
View File

@ -138,6 +138,11 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
await handle_restart_password(update, text) await handle_restart_password(update, text)
return return
# Проверка: не ждём ли завершения OAuth авторизации Qwen
if state.waiting_for_qwen_oauth:
await handle_qwen_oauth_completion(update, text)
return
# ПРОВЕРКА: режим чата с ИИ агентом # ПРОВЕРКА: режим чата с ИИ агентом
if state.ai_chat_mode: if state.ai_chat_mode:
logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}") logger.info(f"Пользователь {user_id} отправил задачу ИИ: {text}")
@ -157,12 +162,46 @@ async def handle_ai_task(update: Update, text: str):
# === ПРОВЕРКА: AI-пресет === # === ПРОВЕРКА: AI-пресет ===
ai_preset = state.ai_preset ai_preset = state.ai_preset
# Если ИИ отключен — пропускаем обработку # Если ИИ отключен — пропускаем обработку
if ai_preset == "off": if ai_preset == "off":
logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку") logger.info(f"Пользователь {user_id}: ИИ отключен, пропускаем обработку")
return 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(): if compactor.check_compaction_needed():
@ -241,8 +280,22 @@ async def handle_ai_task(update: Update, text: str):
"""Callback для накопления полного вывода (не используется для streaming).""" """Callback для накопления полного вывода (не используется для streaming)."""
pass pass
def on_oauth_url(url: str): async def on_oauth_url(url: str):
pass """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): def on_event(event):
"""Обработка событий stream-json для обновления статуса.""" """Обработка событий stream-json для обновления статуса."""
@ -1712,6 +1765,7 @@ async def post_init(application: Application):
BotCommand("cron", "Управление задачами"), BotCommand("cron", "Управление задачами"),
BotCommand("stop", "Прервать SSH-сессию"), BotCommand("stop", "Прервать SSH-сессию"),
BotCommand("restart_bot", "Перезапустить бота"), BotCommand("restart_bot", "Перезапустить бота"),
BotCommand("qwen_auth", "Авторизовать Qwen Code"),
BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"), BotCommand("ai_presets", "🎛️ Выбор AI-провайдера"),
BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"), BotCommand("ai_off", "⌨️ ИИ Отключен (CLI режим)"),
BotCommand("ai_qwen", "💻 Qwen Code (бесплатно)"), 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): async def handle_restart_password(update: Update, text: str):
"""Обработка пароля для перезапуска бота.""" """Обработка пароля для перезапуска бота."""
user_id = update.effective_user.id user_id = update.effective_user.id
@ -2271,6 +2403,7 @@ def main():
application.add_handler(CommandHandler("menu", menu_command)) application.add_handler(CommandHandler("menu", menu_command))
application.add_handler(CommandHandler("stop", stop_command)) application.add_handler(CommandHandler("stop", stop_command))
application.add_handler(CommandHandler("restart_bot", restart_bot_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("memory", memory_command))
application.add_handler(CommandHandler("compact", compact_command)) application.add_handler(CommandHandler("compact", compact_command))
application.add_handler(CommandHandler("facts", facts_command)) application.add_handler(CommandHandler("facts", facts_command))

View File

@ -42,6 +42,9 @@ class UserState:
# Для команды /restart_bot # Для команды /restart_bot
waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска waiting_for_restart_password: bool = False # Ожидание пароля sudo для перезапуска
# Для OAuth авторизации Qwen
waiting_for_qwen_oauth: bool = False # Ожидание завершения OAuth авторизации
class StateManager: class StateManager:

547
bot/utils/qwen_oauth.py Normal file
View File

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

View File

@ -18,6 +18,15 @@ from datetime import datetime, timedelta
from typing import Optional, Dict, Callable, Any, List, Union from typing import Optional, Dict, Callable, Any, List, Union
from enum import Enum 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__) logger = logging.getLogger(__name__)
@ -59,6 +68,7 @@ class QwenSession:
state: QwenSessionState = QwenSessionState.STARTING state: QwenSessionState = QwenSessionState.STARTING
process: Optional[subprocess.Popen] = None process: Optional[subprocess.Popen] = None
oauth_url: Optional[str] = None oauth_url: Optional[str] = None
on_oauth_url: Optional[Callable] = None # Callback для OAuth URL
last_activity: datetime = field(default_factory=datetime.now) last_activity: datetime = field(default_factory=datetime.now)
pending_task: Optional[str] = None pending_task: Optional[str] = None
output_buffer: str = "" output_buffer: str = ""
@ -113,7 +123,68 @@ class QwenCodeManager:
self._sessions[user_id] = session self._sessions[user_id] = session
logger.info(f"Создана сессия Qwen Code для пользователя {user_id}") logger.info(f"Создана сессия Qwen Code для пользователя {user_id}")
return session 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): def close_session(self, user_id: int):
"""Закрыть сессию пользователя.""" """Закрыть сессию пользователя."""
session = self._sessions.pop(user_id, None) session = self._sessions.pop(user_id, None)
@ -155,6 +226,7 @@ class QwenCodeManager:
session.last_activity = datetime.now() session.last_activity = datetime.now()
session.pending_task = task session.pending_task = task
session.on_oauth_url = on_oauth_url # Сохраняем callback для OAuth
# Добавляем системный промпт если нужно # Добавляем системный промпт если нужно
if use_system_prompt: if use_system_prompt:
@ -180,13 +252,14 @@ class QwenCodeManager:
# Запускаем qwen в интерактивном режиме с JSON выводом # Запускаем qwen в интерактивном режиме с JSON выводом
env = os.environ.copy() env = os.environ.copy()
env["FORCE_COLOR"] = "0" # Отключаем цвета для парсинга env["FORCE_COLOR"] = "0" # Отключаем цвета для парсинга
cmd = [ cmd = [
self._qwen_command, self._qwen_command,
"--output-format", "stream-json", "--output-format", "stream-json",
"--input-format", "text", "--input-format", "text",
"--auth-type", "qwen-oauth", # Явное указание типа авторизации
] ]
logger.info(f"Запуск Qwen Code: {' '.join(cmd)}") logger.info(f"Запуск Qwen Code: {' '.join(cmd)}")
session.process = subprocess.Popen( session.process = subprocess.Popen(
@ -286,7 +359,8 @@ class QwenCodeManager:
self._qwen_command, self._qwen_command,
"-p", task, "-p", task,
"--output-format", "stream-json", # Правильный streaming формат "--output-format", "stream-json", # Правильный streaming формат
"--yolo", # Авто-подтверждение "--auth-type", "qwen-oauth", # Явное указание типа авторизации
# НЕ используем --yolo чтобы получить OAuth ссылку если нужно
] ]
logger.info(f"Выполнение задачи (stream-json): {' '.join(cmd)}") logger.info(f"Выполнение задачи (stream-json): {' '.join(cmd)}")
@ -333,6 +407,17 @@ class QwenCodeManager:
session.output_buffer += line_str session.output_buffer += line_str
last_chunk_time = datetime.now() 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 событие и извлекаем текст # Парсим JSON событие и извлекаем текст
await self._process_stream_lines( await self._process_stream_lines(
line_str, on_output, on_chunk, on_event, session line_str, on_output, on_chunk, on_event, session
@ -437,9 +522,23 @@ class QwenCodeManager:
# Проверяем на ошибку # Проверяем на ошибку
if event_data.get('is_error'): if event_data.get('is_error'):
error_text = event_data.get('error', 'Неизвестная ошибка') error_obj = event_data.get('error', {})
logger.error(f"Ошибка Qwen: {error_text}") 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': elif event_type == 'system':
subtype = event_data.get('subtype', '') subtype = event_data.get('subtype', '')
if subtype == 'session_start': if subtype == 'session_start':