chore: merge upstream with local feature additions

- Merged dexscreener connector, composio connectors, crypto realtime tools from upstream
- Kept local additions: dropbox/onedrive connectors, memory routes, model_list routes, RefreshToken model
- Resolved frontend conflicts: kept tool UIs from both sides
- Accepted upstream lock files (uv.lock, pnpm-lock.yaml)
This commit is contained in:
Vonic 2026-04-13 23:31:52 +07:00
commit 6e86cd7e8a
803 changed files with 152168 additions and 14005 deletions

View file

@ -0,0 +1,157 @@
"""
Airtable token refresh utilities.
This module contains shared utilities for refreshing Airtable OAuth tokens.
Extracted from routes to avoid circular imports.
"""
import base64
import logging
from datetime import UTC, datetime, timedelta
import httpx
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.db import SearchSourceConnector
from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
# Airtable OAuth token endpoint
TOKEN_URL = "https://airtable.com/oauth2/v1/token"
def make_basic_auth_header(client_id: str, client_secret: str) -> str:
"""Create HTTP Basic authentication header."""
credentials = f"{client_id}:{client_secret}".encode()
b64 = base64.b64encode(credentials).decode("ascii")
return f"Basic {b64}"
def get_token_encryption() -> TokenEncryption:
"""Get or create token encryption instance."""
if not config.SECRET_KEY:
raise ValueError("SECRET_KEY must be set for token encryption")
return TokenEncryption(config.SECRET_KEY)
async def refresh_airtable_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh the Airtable access token for a connector.
Args:
session: Database session
connector: Airtable connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Airtable token for connector {connector.id}")
credentials = AirtableAuthCredentialsBase.from_dict(connector.config)
# Decrypt tokens if they are encrypted
token_encryption = get_token_encryption()
is_encrypted = connector.config.get("_token_encrypted", False)
refresh_token = credentials.refresh_token
if is_encrypted and refresh_token:
try:
refresh_token = token_encryption.decrypt_token(refresh_token)
except Exception as e:
logger.error(f"Failed to decrypt refresh token: {e!s}")
raise HTTPException(
status_code=500, detail="Failed to decrypt stored refresh token"
) from e
if not refresh_token:
raise HTTPException(
status_code=400,
detail="No refresh token available. Please re-authenticate.",
)
auth_header = make_basic_auth_header(
config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET
)
# Prepare token refresh data
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": config.AIRTABLE_CLIENT_ID,
"client_secret": config.AIRTABLE_CLIENT_SECRET,
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=refresh_data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": auth_header,
},
timeout=30.0,
)
if token_response.status_code != 200:
error_detail = token_response.text
try:
error_json = token_response.json()
error_detail = error_json.get("error_description", error_detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token refresh failed: {error_detail}"
)
token_json = token_response.json()
# Calculate expiration time (UTC, tz-aware)
expires_at = None
if token_json.get("expires_in"):
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
# Encrypt new tokens before storing
access_token = token_json.get("access_token")
new_refresh_token = token_json.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=400, detail="No access token received from Airtable refresh"
)
# Update credentials object with encrypted tokens
credentials.access_token = token_encryption.encrypt_token(access_token)
if new_refresh_token:
credentials.refresh_token = token_encryption.encrypt_token(
new_refresh_token
)
credentials.expires_in = token_json.get("expires_in")
credentials.expires_at = expires_at
credentials.scope = token_json.get("scope")
# Update connector config with encrypted tokens
credentials_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
connector.config = credentials_dict
await session.commit()
await session.refresh(connector)
logger.info(
f"Successfully refreshed Airtable token for connector {connector.id}"
)
return connector
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to refresh Airtable token: {e!s}"
) from e

View file

@ -485,6 +485,56 @@ def validate_connector_config(
if not validators.url(url):
raise ValueError(f"Invalid URL format in INITIAL_URLS: {url}")
def validate_dexscreener_tokens() -> None:
"""Validate DexScreener tokens configuration."""
tokens = config.get("tokens")
if not isinstance(tokens, list) or not tokens:
raise ValueError("tokens must be a non-empty list")
# Valid blockchain names supported by DexScreener
valid_chains = [
"ethereum", "bsc", "polygon", "arbitrum", "optimism", "base",
"solana", "avalanche", "fantom", "cronos", "moonbeam", "moonriver",
"celo", "aurora", "harmony", "metis", "boba", "fuse", "okex",
"heco", "elastos", "telos", "iotex", "thundercore", "tomochain",
"velas", "wanchain", "kardia", "pulsechain", "dogechain", "evmos",
"kava", "step", "godwoken", "milkomeda", "dfk", "swimmer", "rei",
"vision", "smartbch", "redlight", "astar", "shiden", "clover",
"bitgert", "sx", "oasis", "energi", "tombchain", "canto", "kcc",
"ethw", "ethf", "core", "zksync", "polygonzkevm", "linea", "scroll",
"mantle", "manta", "blast", "mode", "xlayer", "merlin", "zkfair",
"opbnb", "taiko", "zeta", "sei", "berachain"
]
for i, token in enumerate(tokens):
if not isinstance(token, dict):
raise ValueError(f"tokens[{i}] must be a dictionary")
# Validate required fields
if "chain" not in token:
raise ValueError(f"tokens[{i}] must have 'chain' field")
if "address" not in token:
raise ValueError(f"tokens[{i}] must have 'address' field")
# Validate chain is valid
chain = token["chain"]
if not isinstance(chain, str) or chain.lower() not in valid_chains:
raise ValueError(
f"tokens[{i}].chain must be one of the supported blockchains. "
f"Got: {chain}. See DexScreener documentation for valid chains."
)
# Validate address format (basic check)
address = token["address"]
if not isinstance(address, str) or not address.strip():
raise ValueError(f"tokens[{i}].address cannot be empty")
# Optional: validate name field if present
if "name" in token:
name = token["name"]
if not isinstance(name, str):
raise ValueError(f"tokens[{i}].name must be a string if provided")
# Lookup table for connector validation rules
connector_rules = {
"SERPER_API": {"required": ["SERPER_API_KEY"], "validators": {}},
@ -578,6 +628,12 @@ def validate_connector_config(
"INITIAL_URLS": lambda: validate_initial_urls(),
},
},
"DEXSCREENER_CONNECTOR": {
"required": ["tokens"],
"validators": {
"tokens": lambda: validate_dexscreener_tokens()
},
},
}
rules = connector_rules.get(connector_type_str)