summaryrefslogtreecommitdiff
path: root/backend-go/internal/esi
diff options
context:
space:
mode:
Diffstat (limited to 'backend-go/internal/esi')
-rw-r--r--backend-go/internal/esi/api.go215
-rw-r--r--backend-go/internal/esi/client.go203
2 files changed, 418 insertions, 0 deletions
diff --git a/backend-go/internal/esi/api.go b/backend-go/internal/esi/api.go
new file mode 100644
index 0000000..706ea32
--- /dev/null
+++ b/backend-go/internal/esi/api.go
@@ -0,0 +1,215 @@
+package esi
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "time"
+)
+
+type WaypointResult struct {
+ RequestedCount int `json:"requested_count"`
+ SuccessCount int `json:"success_count"`
+ AppliedWaypoints []int `json:"applied_waypoints"`
+ FailedWaypoints []int `json:"failed_waypoints"`
+ Errors []string `json:"errors"`
+ Success bool `json:"success"`
+}
+
+type WalletTransaction struct {
+ TransactionID int `json:"transaction_id"`
+ Date time.Time `json:"date"`
+ LocationID int `json:"location_id"`
+ TypeID int `json:"type_id"`
+ UnitPrice float64 `json:"unit_price"`
+ Quantity int `json:"quantity"`
+ ClientID int `json:"client_id"`
+ IsBuy bool `json:"is_buy"`
+ IsPersonal bool `json:"is_personal"`
+ JournalRefID int `json:"journal_ref_id"`
+}
+
+type LocationData struct {
+ SolarSystemID int `json:"solar_system_id"`
+ StationID int `json:"station_id"`
+}
+
+func (c *EsiClient) GetLocation() (*LocationData, error) {
+ characterIDStr := os.Getenv("character_id")
+ if characterIDStr == "" {
+ return nil, fmt.Errorf("character_id not set in environment variables")
+ }
+ characterID, _ := strconv.Atoi(characterIDStr)
+
+ endpoint := fmt.Sprintf("/characters/%d/location/?datasource=tranquility", characterID)
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get location, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+var data LocationData
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+
+ return &data, nil
+}
+
+func (c *EsiClient) SetWaypoints(waypoints []int) (*WaypointResult, error) {
+ res := &WaypointResult{
+ RequestedCount: len(waypoints),
+ Success: true,
+ }
+
+ for i, wp := range waypoints {
+ clearOther := "false"
+ if i == 0 {
+ clearOther = "true"
+ }
+
+ endpoint := fmt.Sprintf("/ui/autopilot/waypoint/?add_to_beginning=false&clear_other_waypoints=%s&destination_id=%d&datasource=tranquility", clearOther, wp)
+
+ resp, err := c.doRequest("POST", endpoint, nil, true)
+ if err != nil {
+ res.FailedWaypoints = append(res.FailedWaypoints, wp)
+ res.Errors = append(res.Errors, err.Error())
+ res.Success = false
+ continue
+ }
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ res.SuccessCount++
+ res.AppliedWaypoints = append(res.AppliedWaypoints, wp)
+ } else {
+ body, _ := io.ReadAll(resp.Body)
+ res.Errors = append(res.Errors, fmt.Sprintf("status %d body %s", resp.StatusCode, string(body)))
+ res.FailedWaypoints = append(res.FailedWaypoints, wp)
+ res.Success = false
+ }
+ resp.Body.Close()
+ time.Sleep(50 * time.Millisecond) // Rate limiting
+ }
+
+ res.Success = len(res.FailedWaypoints) == 0
+ return res, nil
+}
+
+func (c *EsiClient) GetWalletTransactions(fromID *int) ([]WalletTransaction, error) {
+ characterIDStr := os.Getenv("character_id")
+ if characterIDStr == "" {
+ return nil, fmt.Errorf("character_id not set in environment variables")
+ }
+ characterID, _ := strconv.Atoi(characterIDStr)
+ endpoint := fmt.Sprintf("/characters/%d/wallet/transactions/?datasource=tranquility", characterID)
+ if fromID != nil {
+ endpoint += fmt.Sprintf("&from_id=%d", *fromID)
+ }
+
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get transactions, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+ var baseTx []struct {
+ TransactionID int `json:"transaction_id"`
+ Date string `json:"date"`
+ LocationID int `json:"location_id"`
+ TypeID int `json:"type_id"`
+ UnitPrice float64 `json:"unit_price"`
+ Quantity int `json:"quantity"`
+ ClientID int `json:"client_id"`
+ IsBuy bool `json:"is_buy"`
+ IsPersonal bool `json:"is_personal"`
+ JournalRefID int `json:"journal_ref_id"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&baseTx); err != nil {
+ return nil, err
+ }
+
+ var results []WalletTransaction
+ for _, bt := range baseTx {
+ // ESI dates look like `2026-04-26T01:23:45Z`
+ t, _ := time.Parse(time.RFC3339, bt.Date)
+ results = append(results, WalletTransaction{
+ TransactionID: bt.TransactionID,
+ Date: t,
+ LocationID: bt.LocationID,
+ TypeID: bt.TypeID,
+ UnitPrice: bt.UnitPrice,
+ Quantity: bt.Quantity,
+ ClientID: bt.ClientID,
+ IsBuy: bt.IsBuy,
+ IsPersonal: bt.IsPersonal,
+ JournalRefID: bt.JournalRefID,
+ })
+ }
+ return results, nil
+}
+
+func (c *EsiClient) OpenMarketWindow(typeID int) error {
+ endpoint := fmt.Sprintf("/ui/openwindow/marketdetails/?datasource=tranquility&type_id=%d", typeID)
+ resp, err := c.doRequest("POST", endpoint, nil, true)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to open market window, status: %d body: %s", resp.StatusCode, string(body))
+}
+
+func (c *EsiClient) GetRegionOrders(regionID int, page int) ([]map[string]interface{}, string, int, error) {
+ endpoint := fmt.Sprintf("/markets/%d/orders/?datasource=tranquility&order_type=all&page=%d", regionID, page)
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, "", 0, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, "", 0, fmt.Errorf("failed to get region orders, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+ var orders []map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil {
+ return nil, "", 0, err
+ }
+
+ expires := resp.Header.Get("Expires")
+ totalPagesStr := resp.Header.Get("X-Pages")
+ totalPages, _ := strconv.Atoi(totalPagesStr)
+ if totalPages == 0 {
+ totalPages = 1
+ }
+
+ // Add RegionID and drop 0 volume
+ var filtered []map[string]interface{}
+ for _, o := range orders {
+ volRemain, _ := o["volume_remain"].(float64)
+ if volRemain > 0 {
+ o["region_id"] = regionID
+ filtered = append(filtered, o)
+ }
+ }
+
+ return filtered, expires, totalPages, nil
+}
diff --git a/backend-go/internal/esi/client.go b/backend-go/internal/esi/client.go
new file mode 100644
index 0000000..6a3b5fd
--- /dev/null
+++ b/backend-go/internal/esi/client.go
@@ -0,0 +1,203 @@
+package esi
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ EsiBaseURL = "https://esi.evetech.net/latest"
+ UserAgent = "Haul (Hyperspace Asset Unloading and Loading) - Go Port"
+ ErrorLimitThresh = 50
+)
+
+type AuthData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresAt float64 `json:"expires_at"`
+}
+
+type EsiClient struct {
+ HttpClient *http.Client
+ AuthFilePath string
+ TokenMutex sync.Mutex
+ ClientID string
+ SecretKey string
+}
+
+func NewEsiClient(authFilePath string) *EsiClient {
+ clientID := os.Getenv("CLIENT_ID")
+ secretKey := os.Getenv("SECRET_KEY")
+
+ // Default to generic timeout
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ return &EsiClient{
+ HttpClient: client,
+ AuthFilePath: authFilePath,
+ ClientID: clientID,
+ SecretKey: secretKey,
+ }
+}
+
+// GetAccessToken checks if the token is valid, refreshing if needed or forced.
+func (c *EsiClient) GetAccessToken(forceRefresh bool) (string, error) {
+ c.TokenMutex.Lock()
+ defer c.TokenMutex.Unlock()
+
+ data, err := os.ReadFile(c.AuthFilePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read auth file: %w", err)
+ }
+
+ var auth AuthData
+ if err := json.Unmarshal(data, &auth); err != nil {
+ return "", fmt.Errorf("failed to parse auth file: %w", err)
+ }
+
+ now := float64(time.Now().Unix())
+ // Refresh if expired or soon to expire (within 20 mins)
+ if forceRefresh || auth.ExpiresAt < now+1200 {
+ return c.refreshAccessToken(auth.RefreshToken)
+ }
+
+ return auth.AccessToken, nil
+}
+
+func (c *EsiClient) refreshAccessToken(refreshToken string) (string, error) {
+ if c.ClientID == "" || c.SecretKey == "" {
+ return "", errors.New("CLIENT_ID and SECRET_KEY environment variables not set")
+ }
+
+ tokenURL := "https://login.eveonline.com/v2/oauth/token"
+ data := url.Values{}
+ data.Set("grant_type", "refresh_token")
+ data.Set("refresh_token", refreshToken)
+
+ req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", err
+ }
+
+ authStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.ClientID, c.SecretKey)))
+ req.Header.Add("Authorization", "Basic "+authStr)
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Add("Host", "login.eveonline.com")
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("failed to refresh token, status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var respData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
+ return "", err
+ }
+
+ // Update auth file
+ newAuth := AuthData{
+ AccessToken: respData.AccessToken,
+ RefreshToken: respData.RefreshToken,
+ ExpiresAt: float64(time.Now().Unix() + respData.ExpiresIn),
+ }
+
+ // Ensure cache dir exists
+ os.MkdirAll(filepath.Dir(c.AuthFilePath), 0755)
+
+ outBytes, err := json.MarshalIndent(newAuth, "", " ")
+ if err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(c.AuthFilePath, outBytes, 0644); err != nil {
+ return "", err
+ }
+
+ return newAuth.AccessToken, nil
+}
+
+func (c *EsiClient) doRequest(method, endpoint string, body []byte, allowRetry bool) (*http.Response, error) {
+ url := fmt.Sprintf("%s%s", EsiBaseURL, endpoint)
+
+ for attempt := 1; attempt <= 3; attempt++ {
+ token, err := c.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ var reqBody io.Reader
+ if body != nil {
+ reqBody = bytes.NewReader(body)
+ }
+
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Cache-Control", "no-cache")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("User-Agent", UserAgent)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check ESI Error Limit
+ if remainStr := resp.Header.Get("x-esi-error-limit-remain"); remainStr != "" {
+ if remain, err := strconv.Atoi(remainStr); err == nil && remain < ErrorLimitThresh {
+ resp.Body.Close()
+ return nil, fmt.Errorf("ESI error limit threshold reached: %d remaining", remain)
+ }
+ }
+
+ if resp.StatusCode == 401 && allowRetry && attempt == 1 {
+ resp.Body.Close()
+ _, _ = c.GetAccessToken(true) // Force refresh
+ continue
+ }
+
+ if resp.StatusCode == 429 && allowRetry {
+ resp.Body.Close()
+ time.Sleep(time.Duration(2*attempt) * time.Second)
+ continue
+ }
+
+ if resp.StatusCode == 403 {
+ resp.Body.Close()
+ return nil, errors.New("permission denied (403). Check ESI scopes")
+ }
+
+ return resp, nil
+ }
+
+ return nil, errors.New("max retries exceeded")
+} \ No newline at end of file