summaryrefslogtreecommitdiff
path: root/backend/src/esi/api.py
diff options
context:
space:
mode:
Diffstat (limited to 'backend/src/esi/api.py')
-rw-r--r--backend/src/esi/api.py324
1 files changed, 243 insertions, 81 deletions
diff --git a/backend/src/esi/api.py b/backend/src/esi/api.py
index 862b4bf..d8bed26 100644
--- a/backend/src/esi/api.py
+++ b/backend/src/esi/api.py
@@ -6,9 +6,12 @@ import datetime
import json
import logging
import os
+from dataclasses import asdict, dataclass, field
import queue
import threading
import time
+import asyncio
+import aiohttp
from pathlib import Path
from typing import Any
@@ -30,7 +33,35 @@ ERROR_LIMIT_THRESHOLD = 50 # ESI Error limit threshold
REQUEST_RATE_LIMIT = 0.05 # Time in seconds between requests to avoid rate limiting
-def get_location() -> int:
+@dataclass
+class WaypointSubmissionResult:
+ requested_count: int
+ success_count: int = 0
+ applied_waypoints: list[int] = field(default_factory=list)
+ failed_waypoints: list[int] = field(default_factory=list)
+ errors: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ data = asdict(self)
+ data["success"] = self.requested_count > 0 and self.success_count == self.requested_count
+ return data
+
+
+@dataclass(frozen=True)
+class WalletTransaction:
+ transaction_id: int
+ date: datetime.datetime
+ location_id: int
+ type_id: int
+ unit_price: float
+ quantity: int
+ client_id: int
+ is_buy: bool
+ is_personal: bool
+ journal_ref_id: int
+
+
+async def get_location() -> int:
"""
Gets the current location of the character.
@@ -55,42 +86,48 @@ def get_location() -> int:
"datasource": DATASOURCE,
}
- response = requests.get(url, params=params, headers=headers)
- logger.debug(f"Request URL: {response.url}")
- logger.debug(f"Response Status Code: {response.status_code}")
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, params=params, headers=headers) as response:
+ logger.debug(f"Request URL: {response.url}")
+ logger.debug(f"Response Status Code: {response.status}")
- if response.status_code == 401:
- logger.info("Access token expired, refreshing token.")
- access_token(force_refresh=True)
- headers["Authorization"] = f"Bearer {access_token()}"
- response = requests.get(url, params=params, headers=headers)
+ if response.status == 401:
+ logger.info("Access token expired, refreshing token.")
+ # Using run_in_executor for synchronous access_token
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(None, access_token, True)
+ headers["Authorization"] = f"Bearer {access_token()}"
+ async with session.get(url, params=params, headers=headers) as retry_response:
+ response = retry_response
- if int(response.headers.get("X-Esi-Error-Limit-Remain", 100)) < ERROR_LIMIT_THRESHOLD:
- logger.warning("Approaching ESI Error Limit.")
- raise Exception("ESI Error Limit approaching threshold.")
+ if int(response.headers.get("X-Esi-Error-Limit-Remain", 100)) < ERROR_LIMIT_THRESHOLD:
+ logger.warning("Approaching ESI Error Limit.")
+ raise Exception("ESI Error Limit approaching threshold.")
- response.raise_for_status()
- data: dict[str, Any] = response.json()
+ response.raise_for_status()
+ data = await response.json()
- location_id = data.get("station_id") or data.get("solar_system_id")
- if not location_id:
- logger.error("Location ID not found in response.")
- raise ValueError("Unable to determine character's location.")
+ location_id = data.get("station_id") or data.get("solar_system_id")
+ if not location_id:
+ logger.error("Location ID not found in response.")
+ raise ValueError("Unable to determine character's location.")
- logger.info(f"Character location ID: {location_id}")
- return location_id
+ logger.info(f"Character location ID: {location_id}")
+ return location_id
-def set_waypoints(waypoints: list[int]) -> None:
+async def set_waypoints(waypoints: list[int]) -> dict[str, Any]:
"""
Sets the waypoints for the character's autopilot.
Args:
waypoints: A list of waypoint IDs to set.
"""
+ result = WaypointSubmissionResult(requested_count=len(waypoints))
+
if not waypoints:
logger.warning("No waypoints provided.")
- return
+ return result.to_dict()
url = f"{ESI_BASE_URL}/ui/autopilot/waypoint/"
@@ -101,25 +138,58 @@ def set_waypoints(waypoints: list[int]) -> None:
**USER_AGENT_HEADER,
}
- for index, waypoint in enumerate(waypoints):
- params = {
- "add_to_beginning": False,
- "clear_other_waypoints": index == 0,
- "destination_id": waypoint,
- "datasource": DATASOURCE,
- }
+ async with aiohttp.ClientSession() as session:
+ for index, waypoint in enumerate(waypoints):
+ params = {
+ "add_to_beginning": "false",
+ "clear_other_waypoints": "true" if index == 0 else "false",
+ "destination_id": waypoint,
+ "datasource": DATASOURCE,
+ }
+ waypoint_headers = dict(headers)
+ waypoint_error = None
- try:
- response = requests.post(url, params=params, headers=headers)
- response.raise_for_status()
- logger.info(f"Waypoint {waypoint} set successfully.")
- time.sleep(1) # Rate limiting
- except requests.HTTPError as e:
- logger.error(f"Failed to set waypoint {waypoint}: {e}")
- continue
+ for attempt in range(2):
+ try:
+ async with session.post(url, params=params, headers=waypoint_headers) as response:
+ if response.status == 401 and attempt == 0:
+ logger.info("Access token expired while setting waypoint %s, refreshing token.", waypoint)
+ loop = asyncio.get_running_loop()
+ refreshed_token = await loop.run_in_executor(None, access_token, True)
+ waypoint_headers["Authorization"] = f"Bearer {refreshed_token}"
+ continue
+
+ if response.status == 401:
+ waypoint_error = f"Waypoint {waypoint} unauthorized after token refresh"
+ logger.error(waypoint_error)
+ break
+
+ response.raise_for_status()
+ logger.info(f"Waypoint {waypoint} set successfully.")
+ result.success_count += 1
+ result.applied_waypoints.append(waypoint)
+ await asyncio.sleep(1) # Rate limiting
+ waypoint_error = None
+ break
+ except (aiohttp.ClientError, TypeError, ValueError) as error:
+ waypoint_error = f"Failed to set waypoint {waypoint}: {error}"
+ logger.error(waypoint_error)
+ break
+ except Exception as error:
+ waypoint_error = f"Unexpected error while setting waypoint {waypoint}: {error}"
+ logger.exception(waypoint_error)
+ break
+ else:
+ waypoint_error = f"Failed to set waypoint {waypoint}: retries exhausted"
+
+ if waypoint_error is not None:
+ result.failed_waypoints.append(waypoint)
+ result.errors.append(waypoint_error)
+ return result.to_dict()
-def open_market_window(type_id: int) -> None:
+
+async def open_market_window(type_id: int) -> None:
"""
Opens the market window for a specific item type in the game client.
@@ -140,62 +210,154 @@ def open_market_window(type_id: int) -> None:
"datasource": DATASOURCE,
}
- try:
- response = requests.post(url, params=params, headers=headers)
- response.raise_for_status()
- logger.info(f"Market window opened for type ID {type_id}.")
- except requests.HTTPError as e:
- logger.error(f"Failed to open market window for type ID {type_id}: {e}")
+ async with aiohttp.ClientSession() as session:
+ try:
+ async with session.post(url, params=params, headers=headers) as response:
+ response.raise_for_status()
+ logger.info(f"Market window opened for type ID {type_id}.")
+ except aiohttp.ClientError as e:
+ logger.error(f"Failed to open market window for type ID {type_id}: {e}")
-def get_region_orders(region_id: int) -> tuple[list[dict[str, Any]], str]:
+async def get_wallet_transactions(from_id: int | None = None) -> list[WalletTransaction]:
"""
- Retrieves all market orders for a given region.
+ Retrieves recent wallet transactions for the configured character.
Args:
- region_id: The ID of the region.
+ from_id: Optional transaction ID cursor.
Returns:
- A tuple containing the list of orders and the expiry time.
+ A list of normalized wallet transactions.
"""
- def request_orders(region: int, page: int, q: queue.Queue) -> None:
- url = f"{ESI_BASE_URL}/markets/{region}/orders/"
- params = {
- 'datasource': DATASOURCE,
- 'order_type': 'all',
- 'page': page,
- }
- headers = USER_AGENT_HEADER
+ character_id = os.getenv('character_id')
+ if not character_id:
+ logger.error("Character ID not found in environment variables.")
+ raise EnvironmentError("Character ID not set in environment variables.")
- try:
- response = requests.get(url, params=params, headers=headers)
- response.raise_for_status()
- logger.debug(f"Retrieved page {page} for region {region}.")
- q.put(response)
- except requests.HTTPError as e:
- logger.error(f"Error fetching page {page} for region {region}: {e}")
+ url = f"{ESI_BASE_URL}/characters/{character_id}/wallet/transactions/"
+ headers = {
+ "Accept": "application/json",
+ "Cache-Control": "no-cache",
+ "Authorization": f"Bearer {access_token()}",
+ **USER_AGENT_HEADER,
+ }
+ params = {
+ "datasource": DATASOURCE,
+ }
+ if from_id is not None:
+ params["from_id"] = from_id
+
+ async with aiohttp.ClientSession() as session:
+ for attempt in range(2):
+ async with session.get(url, params=params, headers=headers) as response:
+ if response.status == 401 and attempt == 0:
+ logger.info("Access token expired while loading wallet transactions, refreshing token.")
+ loop = asyncio.get_running_loop()
+ refreshed_token = await loop.run_in_executor(None, access_token, True)
+ headers["Authorization"] = f"Bearer {refreshed_token}"
+ continue
+
+ if response.status == 403:
+ error_body = await response.text()
+ logger.error("Wallet transaction access denied: %s", error_body)
+ raise PermissionError(
+ "Wallet transaction access denied. Re-run scripts/setup_auth.py to grant esi-wallet.read_character_wallet.v1."
+ )
+
+ if int(response.headers.get("X-Esi-Error-Limit-Remain", 100)) < ERROR_LIMIT_THRESHOLD:
+ logger.warning("Approaching ESI Error Limit while loading wallet transactions.")
+ raise Exception("ESI Error Limit approaching threshold.")
+
+ response.raise_for_status()
+ payload = await response.json()
+ transactions: list[WalletTransaction] = []
+ for entry in payload:
+ issued_at = datetime.datetime.fromisoformat(entry["date"].replace("Z", "+00:00"))
+ transactions.append(
+ WalletTransaction(
+ transaction_id=entry["transaction_id"],
+ date=issued_at,
+ location_id=entry["location_id"],
+ type_id=entry["type_id"],
+ unit_price=entry["unit_price"],
+ quantity=entry["quantity"],
+ client_id=entry["client_id"],
+ is_buy=entry["is_buy"],
+ is_personal=entry["is_personal"],
+ journal_ref_id=entry["journal_ref_id"],
+ )
+ )
- order_queue = queue.Queue()
- request_orders(region_id, 1, order_queue) # Get the first page
- first_response = order_queue.get()
- pages = int(first_response.headers.get('X-Pages', '1'))
- expires = first_response.headers.get('Expires', '')
+ logger.info("Retrieved %d wallet transactions.", len(transactions))
+ return transactions
+
+ return []
+
+
+async def get_region_orders(region_id: int) -> tuple[list[dict[str, Any]], str]:
+ """
+ Retrieves all market orders for a given region using asyncio and aiohttp.
+
+ Args:
+ region_id: The ID of the region.
+
+ Returns:
+ A tuple containing the list of orders and the expiry time.
+ """
+ url_template = f"{ESI_BASE_URL}/markets/{{region}}/orders/"
+ headers = USER_AGENT_HEADER
- if pages > 1:
- threads = []
- for page in range(2, pages + 1):
- thread = threading.Thread(target=request_orders, args=(region_id, page, order_queue))
- threads.append(thread)
- thread.start()
- time.sleep(REQUEST_RATE_LIMIT) # Rate limiting
+ async def fetch_page(session: aiohttp.ClientSession, region: int, page: int, sem: asyncio.Semaphore) -> tuple[list[dict[str, Any]], str, int] | None:
+ async with sem:
+ params = {
+ 'datasource': DATASOURCE,
+ 'order_type': 'all',
+ 'page': page,
+ }
+ for attempt in range(3):
+ try:
+ async with session.get(url_template.format(region=region), params=params, headers=headers) as response:
+ if response.status == 429:
+ logger.warning(f"Rate limited (429) on page {page} for region {region}. Retrying...")
+ await asyncio.sleep(2 * (attempt + 1))
+ continue
- for thread in threads:
- thread.join()
+ response.raise_for_status()
+ logger.debug(f"Retrieved page {page} for region {region}.")
+
+ pages = int(response.headers.get('X-Pages', '1'))
+ expires = response.headers.get('Expires', '')
+ data = await response.json()
+ return data, expires, pages
+ except aiohttp.ClientError as e:
+ logger.error(f"Error fetching page {page} for region {region} (attempt {attempt + 1}): {e}")
+ if attempt == 2:
+ return None
+ await asyncio.sleep(1)
+ return None
- results: list[dict[str, Any]] = first_response.json()
- while not order_queue.empty():
- response = order_queue.get()
- results.extend(response.json())
+ results: list[dict[str, Any]] = []
+ expires = ''
+ sem = asyncio.Semaphore(15) # Bound concurrency to 15 active requests
+
+ async with aiohttp.ClientSession() as session:
+ # Fetch page 1 to get the total number of pages
+ first_page_result = await fetch_page(session, region_id, 1, sem)
+ if not first_page_result:
+ return [], ''
+
+ data, expires, total_pages = first_page_result
+ results.extend(data)
+
+ if total_pages > 1:
+ tasks = [
+ fetch_page(session, region_id, page, sem)
+ for page in range(2, total_pages + 1)
+ ]
+ pages_data = await asyncio.gather(*tasks)
+ for page_result in pages_data:
+ if page_result:
+ results.extend(page_result[0])
# Filter out orders with zero remaining volume
results = [order for order in results if order.get('volume_remain', 0) > 0]