Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""OAuth integrations for VoIdeaAI."""
|
||||
|
||||
from app.integrations.oauth.yandex import (
|
||||
get_authorize_url as yandex_authorize_url,
|
||||
exchange_code as yandex_exchange_code,
|
||||
get_user_info as yandex_user_info,
|
||||
ensure_app_folder as yandex_ensure_folder,
|
||||
upload_file as yandex_upload_file,
|
||||
get_disk_info as yandex_disk_info,
|
||||
YandexUserInfo,
|
||||
YandexTokenResult,
|
||||
VOIDEA_DISK_FOLDER,
|
||||
)
|
||||
from app.integrations.oauth.google import (
|
||||
is_available as google_is_available,
|
||||
get_authorize_url as google_authorize_url,
|
||||
exchange_code as google_exchange_code,
|
||||
get_user_info as google_user_info,
|
||||
ensure_app_folder as google_ensure_folder,
|
||||
upload_file as google_upload_file,
|
||||
get_disk_info as google_disk_info,
|
||||
GoogleUserInfo,
|
||||
)
|
||||
from app.integrations.oauth.apple import (
|
||||
is_available as apple_is_available,
|
||||
get_authorize_url as apple_authorize_url,
|
||||
exchange_code as apple_exchange_code,
|
||||
get_user_info as apple_user_info,
|
||||
AppleUserInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"yandex_authorize_url",
|
||||
"yandex_exchange_code",
|
||||
"yandex_user_info",
|
||||
"yandex_ensure_folder",
|
||||
"yandex_upload_file",
|
||||
"yandex_disk_info",
|
||||
"YandexUserInfo",
|
||||
"YandexTokenResult",
|
||||
"VOIDEA_DISK_FOLDER",
|
||||
"google_is_available",
|
||||
"google_authorize_url",
|
||||
"google_exchange_code",
|
||||
"google_user_info",
|
||||
"google_ensure_folder",
|
||||
"google_upload_file",
|
||||
"google_disk_info",
|
||||
"GoogleUserInfo",
|
||||
"apple_is_available",
|
||||
"apple_authorize_url",
|
||||
"apple_exchange_code",
|
||||
"apple_user_info",
|
||||
"AppleUserInfo",
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Apple OAuth client with iCloud Drive API support.
|
||||
|
||||
Activated when OAUTH_APPLE_ID is set in .env.
|
||||
Note: Apple requires Sign in with Apple capability and team ID configuration.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
APPLE_OAUTH_URL = "https://appleid.apple.com/auth/authorize"
|
||||
APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"
|
||||
|
||||
SCOPES = "name email"
|
||||
|
||||
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleUserInfo:
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return get_settings().apple_oauth_enabled
|
||||
|
||||
|
||||
async def get_authorize_url() -> str:
|
||||
settings = get_settings()
|
||||
params = {
|
||||
"response_type": "code id_token",
|
||||
"client_id": settings.oauth_apple_id,
|
||||
"redirect_uri": settings.oauth_apple_redirect_uri,
|
||||
"scope": SCOPES,
|
||||
"response_mode": "form_post",
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"{APPLE_OAUTH_URL}?{query}"
|
||||
|
||||
|
||||
async def exchange_code(code: str) -> dict | None:
|
||||
settings = get_settings()
|
||||
if not settings.apple_oauth_enabled:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
APPLE_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": settings.oauth_apple_id,
|
||||
"client_secret": settings.oauth_apple_secret,
|
||||
"redirect_uri": settings.oauth_apple_redirect_uri,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_user_info(access_token: str) -> AppleUserInfo | None:
|
||||
"""Apple returns user info only in the initial authorization response,
|
||||
not from a userinfo endpoint. This method decodes the id_token claims.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://appleid.apple.com/auth/keys",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return None # Full implementation requires JWT id_token decoding
|
||||
|
||||
|
||||
async def ensure_app_folder(access_token: str) -> str | None:
|
||||
return None # iCloud Drive API requires additional entitlements
|
||||
|
||||
|
||||
async def upload_file(
|
||||
access_token: str,
|
||||
file_name: str,
|
||||
file_content: bytes,
|
||||
parent_id: str | None = None,
|
||||
) -> bool:
|
||||
return False # Requires CloudKit API integration
|
||||
|
||||
|
||||
async def get_disk_info(access_token: str) -> dict | None:
|
||||
return None # Requires CloudKit API integration
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Google OAuth client with Google Drive API support.
|
||||
|
||||
Activated when OAUTH_GOOGLE_ID is set in .env.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
GOOGLE_OAUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||
GOOGLE_DRIVE_URL = "https://www.googleapis.com/drive/v3"
|
||||
|
||||
SCOPES = " ".join([
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
])
|
||||
|
||||
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoogleUserInfo:
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return get_settings().google_oauth_enabled
|
||||
|
||||
|
||||
async def get_authorize_url() -> str:
|
||||
settings = get_settings()
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": settings.oauth_google_id,
|
||||
"redirect_uri": settings.oauth_google_redirect_uri,
|
||||
"scope": SCOPES,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"{GOOGLE_OAUTH_URL}?{query}"
|
||||
|
||||
|
||||
async def exchange_code(code: str) -> dict | None:
|
||||
settings = get_settings()
|
||||
if not settings.google_oauth_enabled:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": settings.oauth_google_id,
|
||||
"client_secret": settings.oauth_google_secret,
|
||||
"redirect_uri": settings.oauth_google_redirect_uri,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def get_user_info(access_token: str) -> GoogleUserInfo | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
GOOGLE_USERINFO_URL,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
return GoogleUserInfo(
|
||||
id=data["id"],
|
||||
email=data["email"],
|
||||
display_name=data.get("name", data["email"]),
|
||||
avatar_url=data.get("picture"),
|
||||
)
|
||||
|
||||
|
||||
async def ensure_app_folder(access_token: str) -> str | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{GOOGLE_DRIVE_URL}/files",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"name": VOIDEA_DRIVE_FOLDER,
|
||||
"mimeType": "application/vnd.google-apps.folder",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("id")
|
||||
return None
|
||||
|
||||
|
||||
async def upload_file(
|
||||
access_token: str,
|
||||
file_name: str,
|
||||
file_content: bytes,
|
||||
parent_id: str | None = None,
|
||||
) -> bool:
|
||||
metadata = {"name": file_name}
|
||||
if parent_id:
|
||||
metadata["parents"] = [parent_id]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{GOOGLE_DRIVE_URL}/files?uploadType=multipart",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
data={"metadata": str(metadata)},
|
||||
files={"file": (file_name, file_content)},
|
||||
)
|
||||
return resp.status_code == 200
|
||||
|
||||
|
||||
async def get_disk_info(access_token: str) -> dict | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{GOOGLE_DRIVE_URL}/about?fields=storageQuota",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
return resp.json()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Yandex OAuth client with Disk API support.
|
||||
|
||||
Scopes requested:
|
||||
- login:email — email address
|
||||
- login:info — login, first+last name
|
||||
- login:avatar — avatar URL
|
||||
- cloud_api:disk.write — write anywhere on Disk
|
||||
- cloud_api:disk.app_folder — access app folder on Disk
|
||||
- cloud_api:disk.read — read entire Disk
|
||||
- cloud_api:disk.info — Disk info (quota, etc.)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
YANDEX_OAUTH_URL = "https://oauth.yandex.ru"
|
||||
YANDEX_LOGIN_URL = "https://login.yandex.ru"
|
||||
YANDEX_DISK_URL = "https://cloud-api.yandex.net/v1/disk"
|
||||
|
||||
SCOPES = " ".join([
|
||||
"login:email",
|
||||
"login:info",
|
||||
"login:avatar",
|
||||
"cloud_api:disk.write",
|
||||
"cloud_api:disk.app_folder",
|
||||
"cloud_api:disk.read",
|
||||
"cloud_api:disk.info",
|
||||
])
|
||||
|
||||
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexUserInfo:
|
||||
id: str
|
||||
login: str
|
||||
email: str
|
||||
display_name: str
|
||||
avatar_url: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class YandexTokenResult:
|
||||
access_token: str
|
||||
refresh_token: str | None
|
||||
expires_in: int
|
||||
|
||||
|
||||
async def get_authorize_url() -> str:
|
||||
"""Generate Yandex OAuth authorization URL."""
|
||||
settings = get_settings()
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": settings.oauth_yandex_id,
|
||||
"redirect_uri": settings.oauth_yandex_redirect_uri,
|
||||
"scope": SCOPES,
|
||||
}
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return f"{YANDEX_OAUTH_URL}/authorize?{query}"
|
||||
|
||||
|
||||
async def exchange_code(code: str) -> YandexTokenResult | None:
|
||||
"""Exchange authorization code for access token.
|
||||
|
||||
Args:
|
||||
code: Authorization code from OAuth callback
|
||||
|
||||
Returns:
|
||||
Token result or None if exchange failed
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.oauth_yandex_id or not settings.oauth_yandex_secret:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{YANDEX_OAUTH_URL}/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": settings.oauth_yandex_id,
|
||||
"client_secret": settings.oauth_yandex_secret,
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
return YandexTokenResult(
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data.get("refresh_token"),
|
||||
expires_in=data.get("expires_in", 0),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_info(access_token: str) -> YandexUserInfo | None:
|
||||
"""Get user info from Yandex Login API.
|
||||
|
||||
Args:
|
||||
access_token: Valid OAuth access token
|
||||
|
||||
Returns:
|
||||
User info or None if request failed
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{YANDEX_LOGIN_URL}/info",
|
||||
params={"format": "json"},
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get("default_avatar_id"):
|
||||
avatar_url = (
|
||||
f"https://avatars.yandex.net/get-yapic/{data['default_avatar_id']}/islands-200"
|
||||
)
|
||||
|
||||
display_name = data.get("real_name") or data.get("display_name") or data.get("login", "")
|
||||
email = data.get("default_email") or ""
|
||||
|
||||
return YandexUserInfo(
|
||||
id=str(data["id"]),
|
||||
login=data.get("login", ""),
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_app_folder(access_token: str) -> bool:
|
||||
"""Create VoIdeaAI folder on Yandex.Disk if it doesn't exist.
|
||||
|
||||
Args:
|
||||
access_token: Valid OAuth access token with disk.write scope
|
||||
|
||||
Returns:
|
||||
True if folder exists (created or already present)
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.put(
|
||||
f"{YANDEX_DISK_URL}/resources",
|
||||
params={"path": VOIDEA_DISK_FOLDER},
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
)
|
||||
return resp.status_code in (201, 409)
|
||||
|
||||
|
||||
async def get_disk_info(access_token: str) -> dict | None:
|
||||
"""Get Yandex.Disk info (quota, usage).
|
||||
|
||||
Args:
|
||||
access_token: Valid OAuth access token
|
||||
|
||||
Returns:
|
||||
Disk info dict or None
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{YANDEX_DISK_URL}",
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
)
|
||||
return resp.json() if resp.status_code == 200 else None
|
||||
|
||||
|
||||
async def upload_file(access_token: str, local_path: str, remote_name: str) -> bool:
|
||||
"""Upload a file to VoIdeaAI folder on Yandex.Disk.
|
||||
|
||||
Args:
|
||||
access_token: Valid OAuth access token
|
||||
local_path: Path to local file
|
||||
remote_name: Name for the file on Disk
|
||||
|
||||
Returns:
|
||||
True if upload succeeded
|
||||
"""
|
||||
import os
|
||||
|
||||
if not os.path.exists(local_path):
|
||||
return False
|
||||
|
||||
remote_path = f"{VOIDEA_DISK_FOLDER}/{remote_name}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# 1. Get upload URL
|
||||
resp = await client.get(
|
||||
f"{YANDEX_DISK_URL}/resources/upload",
|
||||
params={"path": remote_path, "overwrite": "true"},
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
upload_url = resp.json().get("href")
|
||||
if not upload_url:
|
||||
return False
|
||||
|
||||
# 2. Upload file
|
||||
with open(local_path, "rb") as f:
|
||||
upload_resp = await client.put(upload_url, content=f)
|
||||
return upload_resp.status_code in (201, 202)
|
||||
Reference in New Issue
Block a user