XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
from __future__ import annotations

import json
import time
import os
import base64
import asyncio
from aiohttp import ClientSession

from ..typing import AsyncResult, Messages, MediaListType
from .base_provider import AsyncGeneratorProvider, ProviderModelMixin
from .helper import format_prompt
from ..providers.response import JsonConversation, FinishReason, ImageResponse
from ..image import is_data_an_media
from ..tools.media import merge_media


class Conversation(JsonConversation):
    """Manages session state for Opera Aria."""
    access_token: str = None
    refresh_token: str = None
    encryption_key: str = None
    expires_at: float = 0
    conversation_id: str = None
    is_first_request: bool = True
    
    def __init__(self, refresh_token: str = None):
        self.refresh_token = refresh_token
        self.encryption_key = base64.b64encode(os.urandom(32)).decode('utf-8')
        self.is_first_request = True
    
    def is_token_expired(self) -> bool:
        return time.time() >= self.expires_at
    
    def update_token(self, access_token: str, expires_in: int):
        self.access_token = access_token
        self.expires_at = time.time() + expires_in - 60


class OperaAria(AsyncGeneratorProvider, ProviderModelMixin):
    label = "Opera Aria"
    url = "https://play.google.com/store/apps/details?id=com.opera.browser"
    
    # Endpoints
    api_endpoint_v1 = "https://composer.opera-api.com/api/v1/a-chat"
    api_endpoint_v2 = "https://composer.opera-api.com/api/v2/a-chat"
    upload_endpoint = "https://composer.opera-api.com/api/v2/files/upload"
    files_endpoint = "https://composer.opera-api.com/api/v2/files/"
    token_endpoint = "https://oauth2.opera-api.com/oauth2/v1/token/"
    signup_endpoint = "https://auth.opera.com/account/v2/external/anonymous/signup"
    
    working = True
    needs_auth = False
    supports_stream = True
    supports_system_message = True
    supports_message_history = True
    
    # Models
    default_model = 'aria'
    default_image_model = default_model
    default_vision_model = default_model
    
    models = [default_model, 'aria-legacy']
    image_models = [default_model]
    vision_models = [default_model]
    
    _model_to_version = {
        'aria': 'v2',
        'aria-legacy': 'v1',
    }
    
    _user_agent_v1 = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36 OPR/89.0.0.0"
    _user_agent_v2 = "Mozilla/5.0 (Linux; U; Android 14; Pixel 8 Pro Build/UQ1A.240205.004; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Mobile Safari/537.36 OPR/99.0.2254.81922"

    @classmethod
    def get_model(cls, model: str) -> str:
        if not model:
            return cls.default_model
        return cls.model_aliases.get(model, model if model in cls.models else cls.default_model)

    @classmethod
    def _get_api_version(cls, model: str) -> str:
        return cls._model_to_version.get(model, 'v2')

    # ==================== Authentication ====================
    
    @classmethod
    async def _generate_refresh_token(cls, session: ClientSession) -> str:
        """Generate refresh token via anonymous signup."""
        # Step 1: Get anonymous access token
        async with session.post(
            cls.token_endpoint,
            headers={
                "User-Agent": "okhttp/5.3.2",
                "Content-Type": "application/x-www-form-urlencoded",
                "x-requested-with": "XMLHttpRequest",
                "x-opera-client-cache": "1"
            },
            data={
                "client_id": "mini-client",
                "client_secret": "Pcc5NvlCrxl02pMw32kO6WrnhpS0pUZ95YrDP8XNKJJQvFht4wQDkFJ7v9x5hn7C",
                "grant_type": "client_credentials",
                "scope": "anonymous_account"
            }
        ) as response:
            response.raise_for_status()
            anon_token = (await response.json())["access_token"]

        # Step 2: Anonymous signup
        async with session.post(
            cls.signup_endpoint,
            headers={
                "User-Agent": "okhttp/5.3.2",
                "Authorization": f"Bearer {anon_token}",
                "Accept": "application/json",
                "Content-Type": "application/json; charset=utf-8",
                "x-requested-with": "XMLHttpRequest",
                "x-opera-client-cache": "1"
            },
            json={"client_id": "mini"}
        ) as response:
            response.raise_for_status()
            auth_token = (await response.json())["token"]

        # Step 3: Exchange auth_token for refresh token
        async with session.post(
            cls.token_endpoint,
            headers={
                "User-Agent": "okhttp/5.3.2",
                "Content-Type": "application/x-www-form-urlencoded",
                "x-requested-with": "XMLHttpRequest",
                "x-opera-client-cache": "1"
            },
            data={
                "auth_token": auth_token,
                "client_id": "mini",
                "grant_type": "auth_token",
                "scope": "shodan:aria"
            }
        ) as response:
            response.raise_for_status()
            return (await response.json())["refresh_token"]

    @classmethod
    async def get_access_token(cls, session: ClientSession, conversation: Conversation) -> str:
        """Get valid access token."""
        if not conversation.refresh_token:
            conversation.refresh_token = await cls._generate_refresh_token(session)

        if conversation.access_token and not conversation.is_token_expired():
            return conversation.access_token
        
        data = {
            "client_id": "mini",
            "grant_type": "refresh_token",
            "refresh_token": conversation.refresh_token,
            "scope": "shodan:aria"
        }
        
        async with session.post(
            cls.token_endpoint,
            headers={
                "User-Agent": "okhttp/5.3.2",
                "Content-Type": "application/x-www-form-urlencoded",
                "x-requested-with": "XMLHttpRequest",
                "x-opera-client-cache": "1"
            },
            data=data
        ) as response:
            response.raise_for_status()
            result = await response.json()
            conversation.update_token(result["access_token"], result.get("expires_in", 3600))
            return result["access_token"]

    # ==================== V2 Media Upload ====================

    @classmethod
    def _detect_mimetype(cls, data: bytes, filename: str = None) -> str:
        """Detect mimetype from bytes."""
        mimetype = is_data_an_media(data, filename)
        if mimetype:
            return mimetype
        if data[:4] == b'\x89PNG':
            return "image/png"
        if data[:2] == b'\xff\xd8':
            return "image/jpeg"
        if data[:4] == b'RIFF' and len(data) > 12 and data[8:12] == b'WEBP':
            return "image/webp"
        if data[:3] == b'GIF':
            return "image/gif"
        return "application/octet-stream"

    @classmethod
    async def _upload_file(cls, session: ClientSession, access_token: str, media_bytes: bytes, filename: str = None) -> str:
        """Upload file via signed GCS URL (V2 only)."""
        mimetype = cls._detect_mimetype(media_bytes, filename)
        size = len(media_bytes)
        
        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
            "Origin": "https://composer.opera-api.com",
            "Referer": "https://composer.opera-api.com/assets/aria/index.html",
            "User-Agent": cls._user_agent_v2,
            "X-Opera-Timezone": "+02:00",
            "X-Opera-UI-Language": "en",
            "X-Requested-With": "com.opera.mini.native"
        }
        
        # Step 1: Get signed URL
        async with session.post(cls.upload_endpoint, headers=headers, json={"mimetype": mimetype, "size": size}) as response:
            response.raise_for_status()
            info = await response.json()
        
        file_id = info["file_id"]
        gcs_headers = info.get("headers", {})
        
        # Step 2: Upload to GCS
        async with session.put(
            info["upload_url"],
            headers={
                "Content-Type": gcs_headers.get("Content-Type", mimetype),
                "X-Goog-Content-Length-Range": gcs_headers.get("X-Goog-Content-Length-Range", f"0,{size}"),
                "Origin": "https://composer.opera-api.com",
                "User-Agent": cls._user_agent_v2
            },
            data=media_bytes
        ) as response:
            response.raise_for_status()
        
        # Step 3: Poll for completion
        for attempt in range(60):
            async with session.get(f"{cls.files_endpoint}{file_id}", headers=headers) as response:
                response.raise_for_status()
                result = await response.json()
                status = result.get("upload_status")
                if status == "finished":
                    return file_id
                if status in ("failed", "error"):
                    raise Exception(f"Upload failed: {result}")
            await asyncio.sleep(min(1 + attempt * 0.3, 2))
        
        raise Exception(f"Upload timeout for {file_id}")

    @classmethod
    async def _process_media(cls, session: ClientSession, access_token: str, media: MediaListType, messages: Messages) -> list:
        """Process and upload all media (V2 only)."""
        attachments = []
        for media_data, media_name in merge_media(media, messages):
            try:
                if isinstance(media_data, str) and media_data.startswith("data:"):
                    media_bytes = base64.b64decode(media_data.split(",", 1)[1])
                elif isinstance(media_data, str) and media_data.startswith(("http://", "https://")):
                    async with session.get(media_data) as response:
                        response.raise_for_status()
                        media_bytes = await response.read()
                elif hasattr(media_data, 'read'):
                    media_bytes = media_data.read()
                elif isinstance(media_data, (str, os.PathLike)):
                    with open(media_data, 'rb') as f:
                        media_bytes = f.read()
                elif isinstance(media_data, bytes):
                    media_bytes = media_data
                else:
                    continue
                
                file_id = await cls._upload_file(session, access_token, media_bytes, media_name)
                attachments.append(file_id)
            except Exception as e:
                import logging
                logging.warning(f"Failed to upload {media_name}: {e}")
        
        return attachments

    # ==================== Request Building ====================

    @classmethod
    def _build_headers(cls, access_token: str, version: str) -> dict:
        """Build request headers."""
        if version == 'v1':
            return {
                "Accept": "text/event-stream",
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
                "Origin": "opera-aria://ui",
                "User-Agent": cls._user_agent_v1,
                "X-Opera-Timezone": "+02:00",
                "X-Opera-UI-Language": "en"
            }
        return {
            "Accept": "text/event-stream",
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
            "Origin": "https://composer.opera-api.com",
            "Referer": "https://composer.opera-api.com/assets/aria/index.html",
            "User-Agent": cls._user_agent_v2,
            "X-Opera-Timezone": "+02:00",
            "X-Opera-UI-Language": "en",
            "X-Requested-With": "com.opera.mini.native"
        }

    @classmethod
    def _build_payload(cls, messages: Messages, conversation: Conversation, attachments: list, version: str, **kwargs) -> dict:
        """Build request payload."""
        query = format_prompt(messages)
        
        if version == 'v1':
            data = {
                "query": query,
                "stream": True,
                "linkify": True,
                "linkify_version": 3,
                "sia": True,
                "media_attachments": [],
                "encryption": {"key": conversation.encryption_key}
            }
        else:
            data = {
                "query": query,
                "sia": True,
                "think_harder": kwargs.get("think_harder", False),
                "supported_features": [],
                "file_attachments": attachments,
                "encryption": {"key": conversation.encryption_key}
            }
        
        if not conversation.is_first_request and conversation.conversation_id:
            data["conversation_id"] = conversation.conversation_id
        
        return data

    # ==================== SSE Parsing ====================

    @classmethod
    def _parse_sse(cls, line: str) -> tuple[str | None, dict | None]:
        """Parse SSE line into (event_type, data)."""
        line = line.strip()
        if line.startswith('event:'):
            return line[6:].strip(), None
        if line.startswith('data:'):
            content = line[5:].strip()
            if content in ('[DONE]', 'null', ''):
                return None, None
            try:
                return None, json.loads(content)
            except json.JSONDecodeError:
                return None, None
        return None, None

    @classmethod
    def _extract_content(cls, data: dict, version: str) -> tuple[str | None, str | None]:
        """
        Extract (text, image_url) from response.
        
        For image responses (content_type == 'image'):
          - Returns image_url, ignores accompanying text message
        For text responses (content_type == 'text'):
          - Returns text message only
        """
        if version == 'v1':
            msg = data.get('message')
            return (msg, None) if isinstance(msg, str) else (None, None)
        
        response = data.get('response', {})
        if not isinstance(response, dict):
            return None, None
        
        content_type = response.get('content_type')
        
        # Image response: extract URL only, skip text like
        # "I've put together the image; take a moment to see it!"
        if content_type == 'image':
            return None, response.get('image_url')
        
        # Text response: extract message only
        text = response.get('message') if isinstance(response.get('message'), str) else None
        return text, None

    @classmethod
    def _extract_conversation_id(cls, data: dict, version: str) -> str | None:
        """Extract conversation ID."""
        if version == 'v1':
            return data.get('conversation_id')
        metadata = data.get('metadata', {})
        return metadata.get('conversation_id') if isinstance(metadata, dict) else None

    # ==================== Main Generator ====================

    @classmethod
    async def create_async_generator(
        cls,
        model: str,
        messages: Messages,
        proxy: str = None,
        refresh_token: str = None,
        conversation: Conversation = None,
        return_conversation: bool = False,
        media: MediaListType = None,
        prompt: str = None,
        **kwargs
    ) -> AsyncResult:
        model = cls.get_model(model)
        version = cls._get_api_version(model)
        api_endpoint = cls.api_endpoint_v1 if version == 'v1' else cls.api_endpoint_v2
        
        if conversation is None:
            conversation = Conversation(refresh_token)
        elif refresh_token and not conversation.refresh_token:
            conversation.refresh_token = refresh_token
        
        async with ClientSession() as session:
            access_token = await cls.get_access_token(session, conversation)
            
            # Upload media (V2 only)
            attachments = []
            if version == 'v2' and (media or any(isinstance(m, dict) and m.get("media") for m in messages)):
                attachments = await cls._process_media(session, access_token, media, messages)
            
            headers = cls._build_headers(access_token, version)
            payload = cls._build_payload(messages, conversation, attachments, version, **kwargs)
            
            # Save original prompt for ImageResponse alt text
            original_prompt = prompt if prompt else (messages[-1]["content"] if messages else "")
            
            async with session.post(api_endpoint, headers=headers, json=payload, proxy=proxy) as response:
                response.raise_for_status()
                
                image_urls = []
                skip_next = False
                
                async for line in response.content:
                    decoded = line.decode('utf-8').strip()
                    if not decoded:
                        continue
                    
                    event_type, data = cls._parse_sse(decoded)
                    
                    if event_type == 'thinking_status':
                        skip_next = True
                        continue
                    
                    if data is None:
                        continue
                    
                    if skip_next:
                        skip_next = False
                        continue
                    
                    text, image_url = cls._extract_content(data, version)
                    
                    if image_url:
                        image_urls.append(image_url)
                    
                    if text:
                        yield text
                    
                    conv_id = cls._extract_conversation_id(data, version)
                    if conv_id:
                        conversation.conversation_id = conv_id
                
                # Yield generated images with original prompt as alt
                if image_urls:
                    yield ImageResponse(image_urls, original_prompt)
                
                conversation.is_first_request = False
                
                if return_conversation:
                    yield conversation