Версия 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:
parent
9854836b17
commit
769c662ab5
|
|
@ -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
139
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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue