diff options
| author | kboz <kutayboz@gmail.com> | 2026-09-27 22:36:34 +0200 |
|---|---|---|
| committer | kboz <kutayboz@gmail.com> | 2026-09-27 22:36:34 +0200 |
| commit | 1b59961ce2385252e3346ea6107b15a2c1feafe6 (patch) | |
| tree | fbe9e0cb968c47cff353e937e4dc835a64058c60 /backend-go/internal/esi | |
| parent | 29c4b57db9a34e0f815d93cac6a2cae543778a93 (diff) | |
| download | haul-1b59961ce2385252e3346ea6107b15a2c1feafe6.tar.gz | |
feat(backend): port route-planning backend to Go
Add a Go implementation mirroring the Python pipeline (loader, graph,
pathfinder, arbitrage, market, route, manager) under internal/core, with
ESI client/API integration and setup endpoints. Reuses backend/src as
the working directory so existing data/cache paths keep working.
Diffstat (limited to 'backend-go/internal/esi')
| -rw-r--r-- | backend-go/internal/esi/api.go | 215 | ||||
| -rw-r--r-- | backend-go/internal/esi/client.go | 203 |
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 |
